Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions e2e/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -369,6 +370,9 @@ on:
image_tag:
required: false
type: string
image_digest:
required: false
type: string
jobs:
%s:
runs-on: ubuntu-latest
Expand Down
23 changes: 14 additions & 9 deletions e2e/scenarios/18-promote-source-image-tag.yaml
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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"
Expand All @@ -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 }}"
41 changes: 32 additions & 9 deletions internal/generate/promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
126 changes: 126 additions & 0 deletions internal/generate/promote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions internal/promote/command_preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
53 changes: 53 additions & 0 deletions internal/promote/command_preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading