From 08998e404a9566c48d82149291ae276b4ee1f3d6 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 13 Jun 2026 22:49:08 -0400 Subject: [PATCH] feat: thread immutable image digest to deploy jobs Signed-off-by: Joshua Temple --- e2e/harness/harness.go | 8 +- .../18-promote-source-image-tag.yaml | 23 ++-- internal/generate/promote.go | 41 ++++-- internal/generate/promote_test.go | 126 ++++++++++++++++++ internal/promote/command_preflight.go | 6 + internal/promote/command_preflight_test.go | 53 ++++++++ internal/promote/preflight.go | 31 +++++ internal/promote/preflight_test.go | 73 ++++++++++ 8 files changed, 341 insertions(+), 20 deletions(-) diff --git a/e2e/harness/harness.go b/e2e/harness/harness.go index 89ba2e98..3d567d83 100644 --- a/e2e/harness/harness.go +++ b/e2e/harness/harness.go @@ -354,8 +354,9 @@ func generateStubWorkflow(name, scenarioTag string) string { } // Declare the inputs a real promote/orchestrate deploy callback accepts so the // generator discovers them and threads the matching preflight outputs through - // each deploy's with: block. image_tag in particular drives the - // source_image_tag passthrough in the generated promote workflow. + // each deploy's with: block. image_tag drives the source_image_tag passthrough + // and image_digest drives the source_image_digest passthrough in the generated + // promote workflow. return fmt.Sprintf(`name: %s on: workflow_call: @@ -369,6 +370,9 @@ on: image_tag: required: false type: string + image_digest: + required: false + type: string jobs: %s: runs-on: ubuntu-latest diff --git a/e2e/scenarios/18-promote-source-image-tag.yaml b/e2e/scenarios/18-promote-source-image-tag.yaml index 3f1b06a5..d676d836 100644 --- a/e2e/scenarios/18-promote-source-image-tag.yaml +++ b/e2e/scenarios/18-promote-source-image-tag.yaml @@ -1,12 +1,15 @@ -name: "Promote threads source image tag to deploys" +name: "Promote threads source image tag and digest to deploys" description: | - Verifies the generated promote.yaml wires the source image tag from the - preflight job into each deploy that accepts an image_tag input. The preflight - job's outputs block must declare source_image_tag, and a deploy whose reusable - workflow declares an image_tag input must receive - image_tag: ${{ needs.preflight.outputs.source_image_tag }} in its with: block. - Without the output declaration the reference resolves to an empty string on - real GitHub and the deploy receives a blank image tag. + Verifies the generated promote.yaml wires both the source image tag and the + immutable source image digest from the preflight job into each deploy that + accepts the matching input. The preflight job's outputs block must declare + source_image_tag and source_image_digest, and a deploy whose reusable workflow + declares image_tag and image_digest inputs must receive + image_tag: ${{ needs.preflight.outputs.source_image_tag }} and + image_digest: ${{ needs.preflight.outputs.source_image_digest }} in its with: + block. Digest threading is additive: it never replaces the tag, so operators + who do not consume the digest are unaffected. Without the output declarations + the references resolve to empty strings on real GitHub. This is a generator-output verification scenario. Assertion runs on the staged repo after StageRepoFromConfig generates workflows, before any run. @@ -20,7 +23,7 @@ config: triggers: ["src/**"] steps: - - name: "Initial commit generates workflows; promote threads source_image_tag to deploy-app" + - name: "Initial commit generates workflows; promote threads source_image_tag and source_image_digest to deploy-app" action: commit commit: message: "feat: add app source" @@ -34,3 +37,5 @@ steps: contains: - "source_image_tag: ${{ steps.preflight.outputs.source_image_tag }}" - "image_tag: ${{ needs.preflight.outputs.source_image_tag }}" + - "source_image_digest: ${{ steps.preflight.outputs.source_image_digest }}" + - "image_digest: ${{ needs.preflight.outputs.source_image_digest }}" diff --git a/internal/generate/promote.go b/internal/generate/promote.go index e9d3837a..215a3071 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -149,9 +149,10 @@ func (g *PromoteGenerator) discoverDeployInputs() error { func (g *PromoteGenerator) validateRequiredInputs() error { // In promote workflow, available inputs come from preflight outputs availableInputs := map[string]string{ - "environment": "preflight.outputs.target_env", - "sha": "preflight.outputs.source_sha", - "image_tag": "preflight.outputs.source_image_tag", + "environment": "preflight.outputs.target_env", + "sha": "preflight.outputs.source_sha", + "image_tag": "preflight.outputs.source_image_tag", + "image_digest": "preflight.outputs.source_image_digest", } var errors []string @@ -165,7 +166,7 @@ func (g *PromoteGenerator) validateRequiredInputs() error { for _, required := range requiredInputs { if _, ok := availableInputs[required]; !ok { errors = append(errors, - fmt.Sprintf("deploy-%s requires input '%s' but it cannot be provided in promote workflow (available: environment, sha, image_tag)", + fmt.Sprintf("deploy-%s requires input '%s' but it cannot be provided in promote workflow (available: environment, sha, image_tag, image_digest)", d.Name, required)) } } @@ -624,6 +625,7 @@ func (g *PromoteGenerator) writePreflightJob(sb *strings.Builder) { sb.WriteString(" source_sha: ${{ steps.preflight.outputs.source_sha }}\n") sb.WriteString(" source_version: ${{ steps.preflight.outputs.source_version }}\n") sb.WriteString(" source_image_tag: ${{ steps.preflight.outputs.source_image_tag }}\n") + sb.WriteString(" source_image_digest: ${{ steps.preflight.outputs.source_image_digest }}\n") sb.WriteString(" changelog_base_sha: ${{ steps.preflight.outputs.changelog_base_sha }}\n") sb.WriteString(" rollback_sha: ${{ steps.preflight.outputs.rollback_sha }}\n") sb.WriteString(" rollback_on_failure: ${{ steps.preflight.outputs.rollback_on_failure }}\n") @@ -781,7 +783,8 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { g.writeInlineDeployBody(sb, d, "${{ needs.preflight.outputs.target_env }}", "${{ needs.preflight.outputs.source_sha }}", - "${{ needs.preflight.outputs.source_image_tag }}") + "${{ needs.preflight.outputs.source_image_tag }}", + "${{ needs.preflight.outputs.source_image_digest }}") continue } @@ -855,6 +858,12 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { if g.deployHasInput(d.Name, "image_tag") { sb.WriteString(" image_tag: ${{ needs.preflight.outputs.source_image_tag }}\n") } + // Additively pass image_digest (the immutable artifact id) when the + // deploy workflow declares it. This is gated independently of image_tag + // so deploys that only want the digest, only the tag, or both all work. + if g.deployHasInput(d.Name, "image_digest") { + sb.WriteString(" image_digest: ${{ needs.preflight.outputs.source_image_digest }}\n") + } // When the callback opts in to dry-run passthrough, forward the // dispatch input so it can emulate internally. if d.SupportsDryRun { @@ -890,10 +899,14 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { if ec, ok := g.config.EnvironmentConfig[finalEnv]; ok && ec.GHAEnvironment != "" { fmt.Fprintf(sb, " environment: %s\n", ec.GHAEnvironment) } + // The prod path uses prod_version as its tag and has no + // prod_image_digest preflight output, so digest pinning is not threaded + // here. Pass an empty digest to keep the prod deploy unchanged. g.writeInlineDeployBody(sb, d, finalEnv, "${{ needs.preflight.outputs.prod_sha }}", - "${{ needs.preflight.outputs.prod_version }}") + "${{ needs.preflight.outputs.prod_version }}", + "") continue } fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(d.Workflow)) @@ -904,6 +917,9 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { if g.deployHasInput(d.Name, "image_tag") { sb.WriteString(" image_tag: ${{ needs.preflight.outputs.prod_version }}\n") } + // image_digest is intentionally not threaded on the prod path: there is no + // prod_image_digest preflight output today, so prod-path digest pinning is + // not yet supported. // When the callback opts in to dry-run passthrough, forward the // dispatch input so it can emulate internally. if d.SupportsDryRun { @@ -918,9 +934,11 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { // writeInlineDeployBody emits the runs-on / steps body of a cascade-owned inline // run: deploy callback in a promote workflow. The standard inputs a reusable -// deploy callback would receive via with: (environment, sha, and image_tag when -// the callback declares it) are surfaced to the inline step as env: variables. -func (g *PromoteGenerator) writeInlineDeployBody(sb *strings.Builder, d config.DeployConfig, environment, sha, imageTag string) { +// deploy callback would receive via with: (environment, sha, image_tag, and +// image_digest when the callback declares them) are surfaced to the inline step +// as env: variables. An empty imageDigest means no digest is available for this +// path (for example the prod path), so IMAGE_DIGEST is omitted. +func (g *PromoteGenerator) writeInlineDeployBody(sb *strings.Builder, d config.DeployConfig, environment, sha, imageTag, imageDigest string) { // Per-callback job attributes (inline-run deploy jobs only): runner selection // (#12), permissions incl. id-token: write OIDC (#35/#15), and concurrency // (#17). The config-level runs_on default applies when the deploy sets no @@ -937,6 +955,11 @@ func (g *PromoteGenerator) writeInlineDeployBody(sb *strings.Builder, d config.D if g.deployHasInput(d.Name, "image_tag") { fmt.Fprintf(sb, " IMAGE_TAG: %s\n", imageTag) } + // Additively surface IMAGE_DIGEST when the callback declares image_digest and + // a digest is available for this path (imageDigest non-empty). + if imageDigest != "" && g.deployHasInput(d.Name, "image_digest") { + fmt.Fprintf(sb, " IMAGE_DIGEST: %s\n", imageDigest) + } // When the callback opts in to dry-run emulation, surface the dispatch input // as DRY_RUN so the inline script can branch on it. if d.SupportsDryRun { diff --git a/internal/generate/promote_test.go b/internal/generate/promote_test.go index 98b10251..a2a97cca 100644 --- a/internal/generate/promote_test.go +++ b/internal/generate/promote_test.go @@ -295,6 +295,132 @@ func TestPromoteGenerator_PreflightDeclaresSourceImageTag(t *testing.T) { "preflight outputs block must declare source_image_tag so deploy jobs resolve a non-empty image_tag") } +// deployWithImageDigestInput is a reusable deploy workflow that accepts both +// image_tag and image_digest, used to verify additive digest threading. +const deployWithImageDigestInput = `name: Deploy +on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + image_tag: + required: false + type: string + image_digest: + required: false + type: string +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: 'true' +` + +// TestPromoteGenerator_PreflightDeclaresSourceImageDigest asserts the preflight +// job outputs block declares source_image_digest so deploy jobs can resolve it. +func TestPromoteGenerator_PreflightDeclaresSourceImageDigest(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "test", "prod"}, + } + + gen := NewPromoteGenerator(cfg, "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, + "source_image_digest: ${{ steps.preflight.outputs.source_image_digest }}", + "preflight outputs block must declare source_image_digest so deploy jobs resolve a digest") +} + +// TestPromoteGenerator_DeployThreadsImageDigestWhenDeclared asserts that when a +// reusable deploy workflow declares an image_digest input, the generated deploy +// job with: block threads BOTH image_tag and image_digest (additive). +func TestPromoteGenerator_DeployThreadsImageDigestWhenDeclared(t *testing.T) { + tmpDir := t.TempDir() + wfDir := filepath.Join(tmpDir, ".github/workflows") + require.NoError(t, os.MkdirAll(wfDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(wfDir, "deploy.yaml"), + []byte(deployWithImageDigestInput), 0644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "test", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + }, + } + + gen := NewPromoteGenerator(cfg, tmpDir) + content, err := gen.Generate() + require.NoError(t, err) + + block := jobBlock(t, content, "deploy-app") + require.NotEmpty(t, block, "deploy-app job not found") + + assert.Contains(t, block, + "image_tag: ${{ needs.preflight.outputs.source_image_tag }}", + "deploy job must still thread image_tag (non-breaking)") + assert.Contains(t, block, + "image_digest: ${{ needs.preflight.outputs.source_image_digest }}", + "deploy job must additively thread image_digest when the workflow declares it") +} + +// TestPromoteGenerator_DeployOmitsImageDigestWhenNotDeclared asserts the +// non-breaking path: a deploy workflow that declares image_tag but NOT +// image_digest gets image_tag only, with no image_digest line emitted. +func TestPromoteGenerator_DeployOmitsImageDigestWhenNotDeclared(t *testing.T) { + tmpDir := t.TempDir() + wfDir := filepath.Join(tmpDir, ".github/workflows") + require.NoError(t, os.MkdirAll(wfDir, 0755)) + deployTagOnly := `name: Deploy +on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + image_tag: + required: false + type: string +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: 'true' +` + require.NoError(t, os.WriteFile(filepath.Join(wfDir, "deploy.yaml"), + []byte(deployTagOnly), 0644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "test", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + }, + } + + gen := NewPromoteGenerator(cfg, tmpDir) + content, err := gen.Generate() + require.NoError(t, err) + + block := jobBlock(t, content, "deploy-app") + require.NotEmpty(t, block, "deploy-app job not found") + + assert.Contains(t, block, + "image_tag: ${{ needs.preflight.outputs.source_image_tag }}", + "deploy job must thread image_tag") + assert.NotContains(t, block, "image_digest:", + "deploy job must NOT emit image_digest when the workflow does not declare it") +} + func TestPromoteGenerator_DryRunSupport(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", diff --git a/internal/promote/command_preflight.go b/internal/promote/command_preflight.go index 6bb8b2b0..70ed08ff 100644 --- a/internal/promote/command_preflight.go +++ b/internal/promote/command_preflight.go @@ -163,6 +163,12 @@ func writePreflightGHAOutput(result *PreflightResult) error { // The promoted artifact version is the canonical image tag for the promotion. // Deploy jobs consume this as their image_tag input. w.Set("source_image_tag", result.SourceVersion) + // source_image_digest is the immutable artifact id threaded alongside the + // mutable tag. Emit it only when present so deploys without a digest are + // unaffected (Writer.Set always writes the key, even when empty). + if result.SourceImageDigest != "" { + w.Set("source_image_digest", result.SourceImageDigest) + } w.Set("changelog_base_sha", result.ChangelogBaseSHA) w.Set("rollback_sha", result.RollbackSHA) diff --git a/internal/promote/command_preflight_test.go b/internal/promote/command_preflight_test.go index c29692c9..1cccb004 100644 --- a/internal/promote/command_preflight_test.go +++ b/internal/promote/command_preflight_test.go @@ -263,6 +263,59 @@ func TestPreflightCommand_GHAOutput(t *testing.T) { // artifact version is the canonical image tag, so it must mirror source_version. require.Contains(t, string(output), "source_image_tag=v1.0.0-rc.1", "preflight must emit source_image_tag set to the source version") + // No source build carries an artifact_id here, so source_image_digest must + // be omitted entirely (guarded on non-empty) rather than emitted empty. + require.NotContains(t, string(output), "source_image_digest=", + "preflight must omit source_image_digest when no source build has an artifact_id") +} + +// TestPreflightCommand_GHAOutput_ImageDigest tests that source_image_digest is +// emitted when the source env's build state carries an artifact_id. +func TestPreflightCommand_GHAOutput_ImageDigest(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "manifest.yaml") + outputPath := filepath.Join(tmpDir, "GITHUB_OUTPUT") + + manifestContent := `ci: + config: + environments: [dev, test, uat, prod] + deploys: + - name: app + workflow: .github/workflows/deploy.yaml + state: + dev: + sha: abc123 + version: v1.0.0-rc.1 + builds: + app: + artifact_id: sha256:deadbeef + test: {} + uat: {} + prod: {} +` + err := os.WriteFile(configPath, []byte(manifestContent), 0644) + require.NoError(t, err) + + err = os.WriteFile(outputPath, []byte(""), 0644) + require.NoError(t, err) + + t.Setenv("GITHUB_OUTPUT", outputPath) + + cmd := NewCommand() + cmd.SetArgs([]string{ + "preflight", + "--mode", "default", + "--config", configPath, + "--gha-output", + }) + + err = cmd.Execute() + require.NoError(t, err) + + output, err := os.ReadFile(outputPath) + require.NoError(t, err) + require.Contains(t, string(output), "source_image_digest=sha256:deadbeef", + "preflight must emit source_image_digest from the source build's artifact_id") } // TestPreflightCommand_AllowBreaking tests --allow-breaking flag. diff --git a/internal/promote/preflight.go b/internal/promote/preflight.go index c2e9e15b..5ad904e1 100644 --- a/internal/promote/preflight.go +++ b/internal/promote/preflight.go @@ -25,6 +25,15 @@ type PreflightResult struct { TargetEnv string `json:"target_env"` SourceSHA string `json:"source_sha"` SourceVersion string `json:"source_version"` + // SourceImageDigest is an immutable artifact identifier (e.g. a Docker image + // content digest) for the source env, threaded alongside the mutable + // SourceVersion so operators can pin deploys to immutable content. It is + // sourced from the source env's build state: cascade treats artifact_id as an + // opaque immutable id, so this is only a real content digest if the operator's + // build populates artifact_id with one. When the source env has multiple + // builds, the first build (by sorted build name) with a non-empty artifact_id + // is used; when none has one, this stays empty and the deploy input is omitted. + SourceImageDigest string `json:"source_image_digest,omitempty"` // Rollback SHA (target env's current SHA before promotion - what we revert to on failure) RollbackSHA string `json:"rollback_sha,omitempty"` @@ -156,6 +165,7 @@ func (p *Preflighter) Run() (*PreflightResult, error) { if state := p.cicdFile.State[first.SourceEnv]; state != nil { result.SourceSHA = state.SHA result.SourceVersion = state.Version + result.SourceImageDigest = firstNonEmptyArtifactID(state.Builds) } // Build envs to update list @@ -591,3 +601,24 @@ func containsBreakingCommit(commits []git.Commit) bool { func stringSliceContains(slice []string, item string) bool { return slices.Contains(slice, item) } + +// firstNonEmptyArtifactID picks a single deterministic artifact identifier from +// an env's per-build state. artifact_id is per-build, but the deploy threads one +// env-level value, so build names are iterated in sorted order and the first +// build with a non-empty artifact_id wins. Returns "" when no build has one, so +// callers can omit the deploy input gracefully. Operators must populate a +// build's artifact_id output with the content digest to use this; cascade treats +// artifact_id as an opaque immutable id. +func firstNonEmptyArtifactID(builds map[string]*config.BuildState) string { + names := make([]string, 0, len(builds)) + for name := range builds { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + if b := builds[name]; b != nil && b.ArtifactID != "" { + return b.ArtifactID + } + } + return "" +} diff --git a/internal/promote/preflight_test.go b/internal/promote/preflight_test.go index 4ed10d9b..107e8440 100644 --- a/internal/promote/preflight_test.go +++ b/internal/promote/preflight_test.go @@ -202,6 +202,79 @@ func TestPreflight_ExtractsPromotionFields(t *testing.T) { require.False(t, result.IsFinalEnv) } +// TestPreflight_SourceImageDigest_FirstSortedNonEmpty asserts that the env-level +// SourceImageDigest is taken from the source env's builds, selecting the first +// build (by sorted build name) that has a non-empty ArtifactID. +func TestPreflight_SourceImageDigest_FirstSortedNonEmpty(t *testing.T) { + cfg := &config.CICDFile{ + Config: &config.TrunkConfig{ + Environments: []string{"dev", "test", "uat", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "app", Triggers: []string{"src/**"}}, + }, + }, + State: map[string]*config.EnvState{ + "dev": { + SHA: "abc123", + Version: "v1.0.0-1", + Builds: map[string]*config.BuildState{ + // "zeta" sorts after "alpha"; "alpha" has an empty + // ArtifactID, so the first sorted NON-empty wins ("beta"). + "zeta": {ArtifactID: "sha256:zzz"}, + "alpha": {ArtifactID: ""}, + "beta": {ArtifactID: "sha256:bbb"}, + }, + }, + }, + } + + pf := NewPreflighter(PreflighterOptions{ + Config: cfg, + Mode: ModeDefault, + BaseDir: "", + }) + result, err := pf.Run() + + require.NoError(t, err) + require.Equal(t, "sha256:bbb", result.SourceImageDigest, + "SourceImageDigest must be the first sorted build with a non-empty artifact_id") +} + +// TestPreflight_SourceImageDigest_EmptyWhenNoArtifact asserts SourceImageDigest +// stays empty (so the deploy input is omitted gracefully) when no source build +// has an artifact_id, preserving non-breaking behavior for deploys without a +// content digest. +func TestPreflight_SourceImageDigest_EmptyWhenNoArtifact(t *testing.T) { + cfg := &config.CICDFile{ + Config: &config.TrunkConfig{ + Environments: []string{"dev", "test", "uat", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "app", Triggers: []string{"src/**"}}, + }, + }, + State: map[string]*config.EnvState{ + "dev": { + SHA: "abc123", + Version: "v1.0.0-1", + Builds: map[string]*config.BuildState{ + "app": {ArtifactID: ""}, + }, + }, + }, + } + + pf := NewPreflighter(PreflighterOptions{ + Config: cfg, + Mode: ModeDefault, + BaseDir: "", + }) + result, err := pf.Run() + + require.NoError(t, err) + require.Empty(t, result.SourceImageDigest, + "SourceImageDigest must be empty when no source build has an artifact_id") +} + func TestPreflight_PrereleaseFinalEnv(t *testing.T) { // Test prerelease and final env detection cfg := &config.CICDFile{