feat(release-editor): pipeline-to-workflow generator + promote integration
Pipeline → Workflow Generator (pipeline-to-workflow.service.ts):
- Converts ReleasePipeline model to stellaops.workflow.definition/v1 canonical JSON
- Maps each phase type to workflow steps:
- preflight → call-transport (scanner verify-digests)
- gate → call-transport (policy-engine evaluate-release-gates) + decision
- approval → activate-task with roles + timeout
- deploy → strategy-specific steps:
- rolling: call-transport with batch config
- canary: loop of (call-transport + timer + decision) per stage
- blue-green: deploy-green → warmup timer → switch-traffic → keepalive → teardown
- A/B: fork (deploy-variant-A, deploy-variant-B) → timer → evaluate-winner
- test → call-transport (taskrunner) + decision on pass/fail
- seal → call-transport (attestor + signer)
- Fallback branches: whenFailure on deploy/test steps → rollback transport
- Expression helpers: str(), num(), bool(), path(), obj(), binary(), setState()
Promote Integration (ReleaseEndpoints.cs):
- ExtractWorkflowName() parses embedded workflow definition from release description
- RequestPromotion now uses the release's custom workflow name if present
- Falls back to generic "release-promotion" workflow for releases without custom pipelines
- Workflow definition JSON embedded in description metadata during seal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -389,8 +389,11 @@ public static class ReleaseEndpoints
|
||||
|
||||
ApprovalEndpoints.SeedData.Approvals.Add(approval);
|
||||
|
||||
// Start the release-promotion workflow (fire-and-forget — workflow runs async)
|
||||
_ = workflowClient.StartWorkflowAsync("release-promotion", new Dictionary<string, object?>
|
||||
// Start the release's pipeline workflow (fire-and-forget — workflow runs async)
|
||||
// If the release has a custom workflow definition embedded in its description,
|
||||
// use that workflow name; otherwise fall back to the generic release-promotion workflow.
|
||||
var workflowName = ExtractWorkflowName(release.Description) ?? "release-promotion";
|
||||
_ = workflowClient.StartWorkflowAsync(workflowName, new Dictionary<string, object?>
|
||||
{
|
||||
["releaseId"] = release.Id,
|
||||
["targetEnvironment"] = targetEnvironment,
|
||||
@@ -800,6 +803,45 @@ public static class ReleaseEndpoints
|
||||
return Results.Ok(new { suggestedVersion = BumpVersion(baseVersion, true), source = baseVersion });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the workflow name from a release description that contains a workflowDefinition metadata line.
|
||||
/// Format: workflowDefinition={"workflowName":"release-platform-release-1-3-1",...}
|
||||
/// </summary>
|
||||
private static string? ExtractWorkflowName(string description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look for workflowDefinition= in the pipe-delimited description
|
||||
foreach (var segment in description.Split('|', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!segment.StartsWith("workflowDefinition=", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = segment["workflowDefinition=".Length..];
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("workflowName", out var nameElement))
|
||||
{
|
||||
return nameElement.GetString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed JSON — fall through to default
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BumpVersion(string version, bool isStable)
|
||||
{
|
||||
// Handle prerelease: 1.3.0-rc1 → 1.3.0-rc2
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PlatformContextStore } from '../../../../core/context/platform-context.
|
||||
import { ScriptPickerComponent } from '../../../../shared/components/script-picker/script-picker.component';
|
||||
import { ReleasePipelineEditorComponent } from './release-pipeline-editor.component';
|
||||
import { type ReleasePipeline, type ReleaseDefaults, createDefaultPipeline } from './release-pipeline.models';
|
||||
import { PipelineToWorkflowService } from './pipeline-to-workflow.service';
|
||||
import type { Script } from '../../../../core/api/scripts.models';
|
||||
|
||||
@Component({
|
||||
@@ -1910,6 +1911,7 @@ export class CreateReleaseComponent implements OnInit {
|
||||
private readonly bundleApi = inject(BundleOrganizerApi);
|
||||
readonly store = inject(ReleaseManagementStore);
|
||||
readonly platformCtx = inject(PlatformContextStore);
|
||||
private readonly pipelineToWorkflow = inject(PipelineToWorkflowService);
|
||||
|
||||
readonly step = signal(1);
|
||||
sealDraft = false;
|
||||
@@ -2344,6 +2346,13 @@ export class CreateReleaseComponent implements OnInit {
|
||||
this.submitError.set(null);
|
||||
this.submitting.set(true);
|
||||
|
||||
// Generate canonical workflow definition from the pipeline
|
||||
const workflowDefinition = this.pipelineToWorkflow.generate(
|
||||
this.releasePipeline(),
|
||||
this.form.name.trim(),
|
||||
this.form.version.trim(),
|
||||
);
|
||||
|
||||
const descriptionLines = [
|
||||
this.form.description.trim(),
|
||||
`type=${this.form.releaseType}`,
|
||||
@@ -2357,6 +2366,7 @@ export class CreateReleaseComponent implements OnInit {
|
||||
`targetStage=${this.targetStage || 'none'}`,
|
||||
`draftIdentity=${this.draftIdentityPreview()}`,
|
||||
`strategyConfig=${this.getActiveStrategyConfigJson()}`,
|
||||
`workflowDefinition=${JSON.stringify(workflowDefinition)}`,
|
||||
this.hasAnyLifecycleHook() ? `lifecycleHooks=${JSON.stringify({
|
||||
preDeploy: this.lifecycleHooks.preDeployScriptId,
|
||||
postDeploy: this.lifecycleHooks.postDeployScriptId,
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import type {
|
||||
ReleasePipeline, PipelinePhase, DeployConfig, TestConfig, ApprovalConfig, GateConfig,
|
||||
CanaryDeployConfig, RollingDeployConfig, BlueGreenDeployConfig, AbDeployConfig,
|
||||
} from './release-pipeline.models';
|
||||
|
||||
/**
|
||||
* Generates a canonical stellaops.workflow.definition/v1 JSON from a ReleasePipeline.
|
||||
*
|
||||
* The mapping:
|
||||
* - preflight → call-transport to scanner verify
|
||||
* - gate → call-transport to policy-engine
|
||||
* - approval → activate-task
|
||||
* - deploy → strategy-specific steps (repeat + call-transport + decision for multi-batch)
|
||||
* - test → call-transport to test-runner + decision on result
|
||||
* - seal → call-transport to attestor + signer
|
||||
*
|
||||
* Fallback branches become whenFailure on call-transport steps.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PipelineToWorkflowService {
|
||||
|
||||
generate(pipeline: ReleasePipeline, releaseName: string, version: string): object {
|
||||
const steps: object[] = [];
|
||||
|
||||
// Initialize state
|
||||
steps.push({
|
||||
$type: 'set-state',
|
||||
stateKey: 'pipelineStatus',
|
||||
valueExpression: str('running'),
|
||||
});
|
||||
|
||||
for (const phase of pipeline.phases) {
|
||||
if (!phase.enabled) continue;
|
||||
steps.push(...this.phaseToSteps(phase));
|
||||
}
|
||||
|
||||
// Final complete
|
||||
steps.push({
|
||||
$type: 'set-state',
|
||||
stateKey: 'pipelineStatus',
|
||||
valueExpression: str('completed'),
|
||||
});
|
||||
steps.push({ $type: 'complete' });
|
||||
|
||||
const workflowName = `release-${slugify(releaseName)}-${slugify(version)}`;
|
||||
|
||||
return {
|
||||
schemaVersion: 'stellaops.workflow.definition/v1',
|
||||
workflowName,
|
||||
workflowVersion: '1.0.0',
|
||||
displayName: `${releaseName} ${version}`,
|
||||
startRequest: {
|
||||
contractName: 'StellaOps.ReleaseOrchestrator.Contracts.ReleasePipelineRequest',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
releaseId: { type: 'string' },
|
||||
targetEnvironment: { type: 'string' },
|
||||
requestedBy: { type: 'string' },
|
||||
},
|
||||
required: ['releaseId', 'targetEnvironment', 'requestedBy'],
|
||||
},
|
||||
allowAdditionalProperties: true,
|
||||
},
|
||||
workflowRoles: ['release-approver', 'release-operator'],
|
||||
businessReference: {
|
||||
keyExpression: path('start.releaseId'),
|
||||
parts: [
|
||||
{ name: 'releaseId', expression: path('start.releaseId') },
|
||||
{ name: 'environment', expression: path('start.targetEnvironment') },
|
||||
],
|
||||
},
|
||||
start: {
|
||||
initializeStateExpression: {
|
||||
$type: 'object',
|
||||
properties: [
|
||||
{ name: 'releaseId', expression: path('start.releaseId') },
|
||||
{ name: 'releaseName', expression: str(releaseName) },
|
||||
{ name: 'releaseVersion', expression: str(version) },
|
||||
{ name: 'targetEnvironment', expression: path('start.targetEnvironment') },
|
||||
{ name: 'requestedBy', expression: path('start.requestedBy') },
|
||||
{ name: 'pipelineStatus', expression: str('pending') },
|
||||
],
|
||||
},
|
||||
initialSequence: { steps },
|
||||
},
|
||||
tasks: this.collectTasks(pipeline),
|
||||
requiredModules: [],
|
||||
};
|
||||
}
|
||||
|
||||
private phaseToSteps(phase: PipelinePhase): object[] {
|
||||
switch (phase.type) {
|
||||
case 'preflight': return this.preflightSteps(phase);
|
||||
case 'gate': return this.gateSteps(phase);
|
||||
case 'approval': return this.approvalSteps(phase);
|
||||
case 'deploy': return this.deploySteps(phase);
|
||||
case 'test': return this.testSteps(phase);
|
||||
case 'seal': return this.sealSteps(phase);
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
private preflightSteps(_phase: PipelinePhase): object[] {
|
||||
return [
|
||||
setState('pipelineStatus', str('preflight')),
|
||||
{
|
||||
$type: 'call-transport',
|
||||
stepName: 'preflight-verify',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'scanner', command: 'verify-digests' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
]),
|
||||
},
|
||||
resultKey: 'preflightResult',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private gateSteps(phase: PipelinePhase): object[] {
|
||||
const config = phase.config as GateConfig;
|
||||
const gateIds = config.gates?.filter(g => g.required).map(g => g.id) ?? [];
|
||||
|
||||
return [
|
||||
setState('pipelineStatus', str('evaluating-gates')),
|
||||
{
|
||||
$type: 'call-transport',
|
||||
stepName: 'evaluate-gates',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'policy-engine', command: 'evaluate-release-gates' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
{ name: 'requiredGates', expression: { $type: 'array', items: gateIds.map(id => str(id)) } },
|
||||
]),
|
||||
},
|
||||
resultKey: 'gateResult',
|
||||
},
|
||||
{
|
||||
$type: 'decision',
|
||||
decisionName: 'gates-check',
|
||||
conditionExpression: binary('==', path('state.gateResult.passed'), bool(true)),
|
||||
whenTrue: { steps: [] },
|
||||
whenElse: {
|
||||
steps: [
|
||||
setState('pipelineStatus', str('gates-failed')),
|
||||
{ $type: 'complete' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private approvalSteps(phase: PipelinePhase): object[] {
|
||||
const config = phase.config as ApprovalConfig;
|
||||
return [
|
||||
setState('pipelineStatus', str('awaiting-approval')),
|
||||
{
|
||||
$type: 'activate-task',
|
||||
taskName: 'Approve Release',
|
||||
runtimeRolesExpression: { $type: 'array', items: (config.roles ?? ['release-approver']).map(r => str(r)) },
|
||||
timeoutSeconds: (config.timeoutHours ?? 48) * 3600,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private deploySteps(phase: PipelinePhase): object[] {
|
||||
const config = phase.config as DeployConfig;
|
||||
const steps: object[] = [
|
||||
setState('pipelineStatus', str('deploying')),
|
||||
];
|
||||
|
||||
const failureHandler = phase.fallback?.autoRollback
|
||||
? { steps: [setState('pipelineStatus', str('rolling-back')), this.rollbackTransportStep(), setState('pipelineStatus', str('rolled-back')), { $type: 'complete' }] }
|
||||
: undefined;
|
||||
|
||||
switch (config.strategy) {
|
||||
case 'rolling':
|
||||
steps.push(this.rollingDeployStep(config.config as RollingDeployConfig, failureHandler));
|
||||
break;
|
||||
case 'canary':
|
||||
steps.push(...this.canaryDeploySteps(config.config as CanaryDeployConfig, failureHandler));
|
||||
break;
|
||||
case 'blue_green':
|
||||
steps.push(...this.blueGreenDeploySteps(config.config as BlueGreenDeployConfig, failureHandler));
|
||||
break;
|
||||
case 'ab-release':
|
||||
steps.push(...this.abDeploySteps(config.config as AbDeployConfig, failureHandler));
|
||||
break;
|
||||
default:
|
||||
steps.push({
|
||||
$type: 'call-transport',
|
||||
stepName: 'deploy-all',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'execute-deployment' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
{ name: 'strategy', expression: str('all-at-once') },
|
||||
]),
|
||||
},
|
||||
resultKey: 'deployResult',
|
||||
...(failureHandler ? { whenFailure: failureHandler } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
private rollingDeployStep(config: RollingDeployConfig, failureHandler?: object): object {
|
||||
return {
|
||||
$type: 'call-transport',
|
||||
stepName: 'rolling-deploy',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'execute-deployment' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
{ name: 'strategy', expression: str('rolling') },
|
||||
{ name: 'batchSize', expression: num(config.batchSize) },
|
||||
{ name: 'batchSizeType', expression: str(config.batchSizeType) },
|
||||
{ name: 'stabilizationTime', expression: num(config.stabilizationTime) },
|
||||
{ name: 'maxFailedBatches', expression: num(config.maxFailedBatches) },
|
||||
{ name: 'healthCheckType', expression: str(config.healthCheckType) },
|
||||
]),
|
||||
},
|
||||
resultKey: 'deployResult',
|
||||
...(failureHandler ? { whenFailure: failureHandler } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private canaryDeploySteps(config: CanaryDeployConfig, failureHandler?: object): object[] {
|
||||
const steps: object[] = [];
|
||||
for (let i = 0; i < config.stages.length; i++) {
|
||||
const stage = config.stages[i];
|
||||
steps.push({
|
||||
$type: 'call-transport',
|
||||
stepName: `canary-stage-${i + 1}`,
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'execute-canary-stage' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
{ name: 'trafficPercent', expression: num(stage.trafficPercent) },
|
||||
{ name: 'stageIndex', expression: num(i) },
|
||||
]),
|
||||
},
|
||||
resultKey: `canaryStage${i + 1}Result`,
|
||||
...(failureHandler ? { whenFailure: failureHandler } : {}),
|
||||
});
|
||||
|
||||
// Metrics window timer + check
|
||||
if (i < config.stages.length - 1) {
|
||||
steps.push({
|
||||
$type: 'timer',
|
||||
stepName: `canary-metrics-window-${i + 1}`,
|
||||
delayExpression: str(`PT${stage.durationMinutes}M`),
|
||||
});
|
||||
steps.push({
|
||||
$type: 'decision',
|
||||
decisionName: `canary-metrics-check-${i + 1}`,
|
||||
conditionExpression: binary('<=',
|
||||
path(`state.canaryStage${i + 1}Result.errorRate`),
|
||||
num(config.errorRateThreshold / 100)),
|
||||
whenTrue: { steps: [] },
|
||||
whenElse: failureHandler ?? { steps: [setState('pipelineStatus', str('canary-failed')), { $type: 'complete' }] },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Full rollout
|
||||
steps.push({
|
||||
$type: 'call-transport',
|
||||
stepName: 'canary-full-rollout',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'execute-canary-stage' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
{ name: 'trafficPercent', expression: num(100) },
|
||||
{ name: 'stageIndex', expression: num(config.stages.length) },
|
||||
]),
|
||||
},
|
||||
resultKey: 'canaryFullResult',
|
||||
});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
private blueGreenDeploySteps(config: BlueGreenDeployConfig, failureHandler?: object): object[] {
|
||||
return [
|
||||
{
|
||||
$type: 'call-transport',
|
||||
stepName: 'deploy-green',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'deploy-green-environment' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
]),
|
||||
},
|
||||
resultKey: 'greenDeployResult',
|
||||
...(failureHandler ? { whenFailure: failureHandler } : {}),
|
||||
},
|
||||
{
|
||||
$type: 'timer',
|
||||
stepName: 'green-warmup',
|
||||
delayExpression: str(`PT${config.warmupPeriodSeconds}S`),
|
||||
},
|
||||
{
|
||||
$type: 'call-transport',
|
||||
stepName: 'switch-traffic',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'switch-traffic' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
{ name: 'mode', expression: str(config.switchoverMode) },
|
||||
]),
|
||||
},
|
||||
resultKey: 'switchResult',
|
||||
...(failureHandler ? { whenFailure: failureHandler } : {}),
|
||||
},
|
||||
{
|
||||
$type: 'timer',
|
||||
stepName: 'blue-keepalive',
|
||||
delayExpression: str(`PT${config.blueKeepaliveMinutes}M`),
|
||||
},
|
||||
{
|
||||
$type: 'call-transport',
|
||||
stepName: 'teardown-blue',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'teardown-old-environment' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
]),
|
||||
},
|
||||
resultKey: 'teardownResult',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private abDeploySteps(config: AbDeployConfig, failureHandler?: object): object[] {
|
||||
return [
|
||||
{
|
||||
$type: 'fork',
|
||||
stepName: 'deploy-ab-variants',
|
||||
branches: [
|
||||
{
|
||||
steps: [{
|
||||
$type: 'call-transport',
|
||||
stepName: 'deploy-variant-a',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'deploy-variant' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'variant', expression: str('A') },
|
||||
]),
|
||||
},
|
||||
resultKey: 'variantAResult',
|
||||
}],
|
||||
},
|
||||
{
|
||||
steps: [{
|
||||
$type: 'call-transport',
|
||||
stepName: 'deploy-variant-b',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'deploy-variant' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'variant', expression: str('B') },
|
||||
]),
|
||||
},
|
||||
resultKey: 'variantBResult',
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
$type: 'timer',
|
||||
stepName: 'ab-metrics-collection',
|
||||
delayExpression: str(`PT${config.metricsCollectionHours}H`),
|
||||
},
|
||||
{
|
||||
$type: 'call-transport',
|
||||
stepName: 'evaluate-ab-winner',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'evaluate-ab-results' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
]),
|
||||
},
|
||||
resultKey: 'abEvaluationResult',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private rollbackTransportStep(): object {
|
||||
return {
|
||||
$type: 'call-transport',
|
||||
stepName: 'execute-rollback',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'execute-rollback' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
]),
|
||||
},
|
||||
resultKey: 'rollbackResult',
|
||||
};
|
||||
}
|
||||
|
||||
private testSteps(phase: PipelinePhase): object[] {
|
||||
const config = phase.config as TestConfig;
|
||||
const failureHandler = phase.fallback?.autoRollback
|
||||
? { steps: [setState('pipelineStatus', str('test-failed')), this.rollbackTransportStep(), { $type: 'complete' }] }
|
||||
: undefined;
|
||||
|
||||
return [
|
||||
setState('pipelineStatus', str(`testing-${config.testType}`)),
|
||||
{
|
||||
$type: 'call-transport',
|
||||
stepName: `test-${config.testType}`,
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'taskrunner', command: `execute-${config.testType}-test` },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
{ name: 'testType', expression: str(config.testType) },
|
||||
...(config.scriptId ? [{ name: 'scriptId', expression: str(config.scriptId) }] : []),
|
||||
]),
|
||||
},
|
||||
resultKey: 'testResult',
|
||||
timeoutSeconds: config.timeoutSeconds,
|
||||
...(failureHandler ? { whenFailure: failureHandler } : {}),
|
||||
},
|
||||
{
|
||||
$type: 'decision',
|
||||
decisionName: `test-${config.testType}-check`,
|
||||
conditionExpression: binary('==', path('state.testResult.passed'), bool(true)),
|
||||
whenTrue: { steps: [] },
|
||||
whenElse: failureHandler ?? { steps: [setState('pipelineStatus', str('test-failed')), { $type: 'complete' }] },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private sealSteps(_phase: PipelinePhase): object[] {
|
||||
return [
|
||||
setState('pipelineStatus', str('sealing-evidence')),
|
||||
{
|
||||
$type: 'call-transport',
|
||||
stepName: 'generate-attestation',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'attestor', command: 'generate-release-attestation' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'environment', expression: path('state.targetEnvironment') },
|
||||
]),
|
||||
},
|
||||
resultKey: 'attestationResult',
|
||||
},
|
||||
{
|
||||
$type: 'call-transport',
|
||||
stepName: 'sign-evidence',
|
||||
invocation: {
|
||||
address: { $type: 'microservice', microserviceName: 'signer', command: 'sign-release-evidence' },
|
||||
payloadExpression: obj([
|
||||
{ name: 'releaseId', expression: path('state.releaseId') },
|
||||
{ name: 'attestationId', expression: path('state.attestationResult.attestationId') },
|
||||
]),
|
||||
},
|
||||
resultKey: 'signResult',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private collectTasks(pipeline: ReleasePipeline): object[] {
|
||||
const tasks: object[] = [];
|
||||
for (const phase of pipeline.phases) {
|
||||
if (!phase.enabled || phase.type !== 'approval') continue;
|
||||
const config = phase.config as ApprovalConfig;
|
||||
tasks.push({
|
||||
taskName: 'Approve Release',
|
||||
taskType: 'approval',
|
||||
routeExpression: str('release-approval'),
|
||||
payloadExpression: path('state'),
|
||||
taskRoles: config.roles ?? ['release-approver'],
|
||||
onComplete: {
|
||||
steps: [
|
||||
{
|
||||
$type: 'decision',
|
||||
decisionName: 'approval-decision',
|
||||
conditionExpression: binary('==', path('task.result.decision'), str('approved')),
|
||||
whenTrue: { steps: [setState('pipelineStatus', str('approved'))] },
|
||||
whenElse: { steps: [setState('pipelineStatus', str('rejected')), { $type: 'complete' }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expression helpers ──
|
||||
|
||||
function str(value: string): object { return { $type: 'string', value }; }
|
||||
function num(value: number): object { return { $type: 'number', value: String(value) }; }
|
||||
function bool(value: boolean): object { return { $type: 'boolean', value }; }
|
||||
function path(p: string): object { return { $type: 'path', path: p }; }
|
||||
function obj(properties: Array<{ name: string; expression: object }>): object { return { $type: 'object', properties }; }
|
||||
function binary(operator: string, left: object, right: object): object { return { $type: 'binary', operator, left, right }; }
|
||||
function setState(key: string, value: object): object { return { $type: 'set-state', stateKey: key, valueExpression: value }; }
|
||||
function slugify(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'release'; }
|
||||
Reference in New Issue
Block a user