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:
master
2026-04-07 12:19:03 +03:00
parent 524f085aca
commit e0c537c427
3 changed files with 571 additions and 2 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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'; }