From 62d30fd52587050977839f09d6f7da5b80f44c43 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 13 Jun 2026 16:11:18 -0400 Subject: [PATCH] fix: emit and declare preflight source_image_tag for promote deploys Promote deploy jobs reference needs.preflight.outputs.source_image_tag for their image_tag input, but the preflight job never declared that output and the preflight CLI never emitted it, so on real GitHub the reference resolved to an empty string and deploys received a blank image tag (actionlint also flagged the undefined property). The promoted artifact version is the canonical image tag, so the preflight CLI now emits source_image_tag set to the source version, with the same availability as source_version, and the generated preflight job declares the matching output. Signed-off-by: Joshua Temple --- e2e/harness/harness.go | 14 ++++++++ .../18-promote-source-image-tag.yaml | 36 +++++++++++++++++++ internal/generate/promote.go | 1 + internal/generate/promote_test.go | 20 +++++++++++ internal/promote/command_preflight.go | 3 ++ internal/promote/command_preflight_test.go | 4 +++ 6 files changed, 78 insertions(+) create mode 100644 e2e/scenarios/18-promote-source-image-tag.yaml diff --git a/e2e/harness/harness.go b/e2e/harness/harness.go index 2b053384..254b65bb 100644 --- a/e2e/harness/harness.go +++ b/e2e/harness/harness.go @@ -344,9 +344,23 @@ func generateStubWorkflow(name, scenarioTag string) string { if scenarioTag != "" { displayName = fmt.Sprintf("%s [scenario-%s]", name, scenarioTag) } + // 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. return fmt.Sprintf(`name: %s on: workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + image_tag: + 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 new file mode 100644 index 00000000..3f1b06a5 --- /dev/null +++ b/e2e/scenarios/18-promote-source-image-tag.yaml @@ -0,0 +1,36 @@ +name: "Promote threads source image tag 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. + + This is a generator-output verification scenario. Assertion runs on the staged + repo after StageRepoFromConfig generates workflows, before any run. + +config: + trunk_branch: main + environments: [dev, test, prod] + deploys: + - name: app + workflow: .github/workflows/deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Initial commit generates workflows; promote threads source_image_tag to deploy-app" + action: commit + commit: + message: "feat: add app source" + files: + src/app.go: | + package main + func main() {} + expect: + workflow_files: + - path: ".github/workflows/promote.yaml" + contains: + - "source_image_tag: ${{ steps.preflight.outputs.source_image_tag }}" + - "image_tag: ${{ needs.preflight.outputs.source_image_tag }}" diff --git a/internal/generate/promote.go b/internal/generate/promote.go index b71de80f..eac27df7 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -626,6 +626,7 @@ func (g *PromoteGenerator) writePreflightJob(sb *strings.Builder) { sb.WriteString(" target_env: ${{ steps.preflight.outputs.target_env }}\n") 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(" 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") diff --git a/internal/generate/promote_test.go b/internal/generate/promote_test.go index 0a93c8f3..455fb74b 100644 --- a/internal/generate/promote_test.go +++ b/internal/generate/promote_test.go @@ -203,6 +203,26 @@ func TestPromoteGenerator_PublishOnProd(t *testing.T) { assert.Contains(t, content, "rc_version") } +// TestPromoteGenerator_PreflightDeclaresSourceImageTag verifies the preflight job +// declares a source_image_tag output. Deploy jobs reference +// needs.preflight.outputs.source_image_tag for the image_tag input, so the +// preflight outputs block must declare it or the reference resolves to an empty +// string on real GitHub (and actionlint flags an undefined property). +func TestPromoteGenerator_PreflightDeclaresSourceImageTag(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_tag: ${{ steps.preflight.outputs.source_image_tag }}", + "preflight outputs block must declare source_image_tag so deploy jobs resolve a non-empty image_tag") +} + 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 7dad5122..6bb8b2b0 100644 --- a/internal/promote/command_preflight.go +++ b/internal/promote/command_preflight.go @@ -160,6 +160,9 @@ func writePreflightGHAOutput(result *PreflightResult) error { w.Set("target_env", result.TargetEnv) w.Set("source_sha", result.SourceSHA) w.Set("source_version", result.SourceVersion) + // 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) 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 ffc88dd1..c29692c9 100644 --- a/internal/promote/command_preflight_test.go +++ b/internal/promote/command_preflight_test.go @@ -259,6 +259,10 @@ func TestPreflightCommand_GHAOutput(t *testing.T) { require.NoError(t, err) require.Contains(t, string(output), "source_env=") require.Contains(t, string(output), "target_env=") + // Deploy jobs consume source_image_tag for their image_tag input. The promoted + // 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") } // TestPreflightCommand_AllowBreaking tests --allow-breaking flag.