diff --git a/e2e/scenarios/hotfix/hotfix-rollback-runtime.yaml b/e2e/scenarios/hotfix/hotfix-rollback-runtime.yaml new file mode 100644 index 00000000..385ae784 --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-rollback-runtime.yaml @@ -0,0 +1,55 @@ +name: "Hotfix Rollback Runtime" +description: | + Verifies that the generated cascade-hotfix.yaml wires a real rollback SHA into + its auto-rollback path. The context job must check out the repo, read the + target env's pre-hotfix state SHA from the manifest via yq, and emit it as the + rollback_sha output (no empty placeholder). Each deploy must be paired with a + rollback job gated on a non-empty rollback_sha and a failed deploy. + + A full failed-deploy simulation would require driving the entire + cherry-pick -> PR -> merge -> deploy flow and forcing a conditional failure in + the inline deploy stub, which the act-based harness cannot reliably stage. This + scenario therefore verifies the generated wiring (the unit of behavior that the + earlier empty-placeholder bug broke). + + Generator-output verification only. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: deploy-test + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-prod + workflow: deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Initial commit; assert hotfix wires a real rollback_sha" + action: commit + commit: + message: "feat: add app" + files: + src/app.go: | + package main + func main() {} + expect: + workflow_files: + - path: ".github/workflows/cascade-hotfix.yaml" + contains: + # Context job reads the target env state SHA from the manifest. + - "yq eval" + - ".state." + # Non-empty rollback_sha output (the bug emitted an empty echo). + - "rollback_sha=${ROLLBACK_SHA}" + # Correctly-gated rollback jobs, one per deploy. + - "rollback-deploy-test:" + - "rollback-deploy-prod:" + - "if: always() && needs.context.outputs.rollback_sha != '' && needs.deploy-deploy-prod.result == 'failure'" + not_contains: + - "echo \"rollback_sha=\"" diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index 6b07d5a0..81dbbe03 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -350,6 +350,12 @@ func (g *HotfixGenerator) writeContextJob(sb *strings.Builder) { sb.WriteString(" base_sha: ${{ steps.ctx.outputs.base_sha }}\n") sb.WriteString(" rollback_sha: ${{ steps.ctx.outputs.rollback_sha }}\n") sb.WriteString(" steps:\n") + // The context job reads the target env's pre-hotfix state SHA from the + // committed manifest, so the repo must be checked out first. fetch-depth: 0 + // matches the other hotfix jobs that need full history available. + writeActionStep(sb, g.config, " ", actionCheckout) + sb.WriteString(" with:\n") + sb.WriteString(" fetch-depth: 0\n") sb.WriteString(" - name: Derive target environment and hotfix SHAs\n") sb.WriteString(" id: ctx\n") sb.WriteString(" env:\n") @@ -363,11 +369,20 @@ func (g *HotfixGenerator) writeContextJob(sb *strings.Builder) { // command enforces that the required SHAs are present. sb.WriteString(" FIX_SHA=$(printf '%s\\n' \"$PR_BODY\" | grep -m1 '^Cascade-Hotfix-Source:' | sed 's/^Cascade-Hotfix-Source:[[:space:]]*//' || true)\n") sb.WriteString(" BASE_SHA=$(printf '%s\\n' \"$PR_BODY\" | grep -m1 '^Cascade-Hotfix-Base:' | sed 's/^Cascade-Hotfix-Base:[[:space:]]*//' || true)\n") + // Resolve the auto-rollback target: the target env's state SHA as recorded in + // the manifest before this hotfix deploys (the N-1 deployment). yq emits "" for + // an absent env/state so the downstream rollback gate (rollback_sha != '') + // stays closed until a prior deployment exists. Mirrors the release generator's + // ".$MANIFEST_KEY.state..sha" read. + fmt.Fprintf(sb, " MANIFEST_FILE=\"%s\"\n", g.getManifestFilePath()) + fmt.Fprintf(sb, " MANIFEST_KEY=\"%s\"\n", g.config.GetManifestKey()) + sb.WriteString(" ROLLBACK_SHA=$(yq eval \".$MANIFEST_KEY.state.${TARGET_ENV}.sha // \\\"\\\"\" \"$MANIFEST_FILE\")\n") + sb.WriteString(" if [ \"$ROLLBACK_SHA\" = \"null\" ]; then ROLLBACK_SHA=\"\"; fi\n") sb.WriteString(" {\n") sb.WriteString(" echo \"target_env=${TARGET_ENV}\"\n") sb.WriteString(" echo \"fix_sha=${FIX_SHA}\"\n") sb.WriteString(" echo \"base_sha=${BASE_SHA}\"\n") - sb.WriteString(" echo \"rollback_sha=\"\n") + sb.WriteString(" echo \"rollback_sha=${ROLLBACK_SHA}\"\n") sb.WriteString(" } >> \"$GITHUB_OUTPUT\"\n") } @@ -477,6 +492,12 @@ func (g *HotfixGenerator) writeDeployJobs(sb *strings.Builder) { // Rollback job: gated on a rollback sha being available and the deploy // failing, mirroring the promote workflow's rollback shape. + // + // Hotfix auto-rollback is always-on when an N-1 SHA exists (rollback_sha + // non-empty). Unlike promote, hotfix has no preflight job to carry an + // explicit rollback_on_failure opt-in signal. Adding a new manifest knob is + // out of scope; always-on matches the inherent N-1 model of hotfix + // deployments. fmt.Fprintf(sb, " rollback-%s:\n", d.Name) fmt.Fprintf(sb, " name: Rollback %s\n", d.Name) fmt.Fprintf(sb, " needs: [context, deploy-%s]\n", d.Name) diff --git a/internal/generate/hotfix_test.go b/internal/generate/hotfix_test.go index 317beae6..d303e22a 100644 --- a/internal/generate/hotfix_test.go +++ b/internal/generate/hotfix_test.go @@ -259,6 +259,53 @@ func TestHotfixGeneratorE2E(t *testing.T) { assert.False(t, NewHotfixGenerator(singleCfg, tmpDir).Enabled(), "single-env manifest emits nothing") } +// TestHotfixGenerator_ContextEmitsNonEmptyRollbackSHA guards the rollback_sha +// wiring regression: the context job formerly emitted an empty `rollback_sha=`, +// so the rollback- gate (which requires rollback_sha != '') never fired. +// The context job must check out the repo and read the target env's pre-hotfix +// state SHA from the manifest via yq, then emit it as the rollback_sha output. +func TestHotfixGenerator_ContextEmitsNonEmptyRollbackSHA(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + contextJob := extractJobSection(t, content, "context:") + require.NotEmpty(t, contextJob, "context job section should be present") + + // The context job must check out the repo so the manifest is on disk for yq. + assert.Contains(t, contextJob, "actions/checkout", + "context job must check out the repo to read the manifest") + + // It must read the target env's pre-hotfix state SHA from the manifest. + assert.Contains(t, contextJob, "yq eval", + "context job must read the rollback SHA from the manifest via yq") + assert.Contains(t, contextJob, ".state.", + "context job must read .state..sha from the manifest") + assert.Contains(t, contextJob, ".sha", + "context job must read the state sha field") + + // The empty placeholder echo must be gone, replaced by a non-empty assignment. + assert.NotContains(t, contextJob, `echo "rollback_sha="`, + "context job must not emit an empty rollback_sha placeholder") + assert.Contains(t, contextJob, "rollback_sha=${ROLLBACK_SHA}", + "context job must emit the resolved rollback SHA as its output") +} + +// TestHotfixGenerator_RollbackJobGatedCorrectly confirms the rollback job exists +// and is gated on a non-empty rollback_sha and a failed deploy, mirroring the +// promote workflow's rollback shape. +func TestHotfixGenerator_RollbackJobGatedCorrectly(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, " rollback-service:", + "a rollback job must be emitted per deploy") + assert.Contains(t, content, + "if: always() && needs.context.outputs.rollback_sha != '' && needs.deploy-service.result == 'failure'", + "rollback job must be gated on a non-empty rollback_sha and a failed deploy") +} + // hotfixFlagInvocation captures a single `cascade hotfix --flag` // pairing parsed from the generated workflow, for cross-checking against the // real cobra command tree. diff --git a/internal/generate/release.go b/internal/generate/release.go index 4d914549..e47b845b 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -150,6 +150,10 @@ func (g *ReleaseGenerator) writeWorkflowTriggers(sb *strings.Builder) { sb.WriteString("\n") } +// The release workflow tags, generates the changelog, and publishes a GitHub +// release; it performs no environment deploy, so there is no deploy to +// auto-roll-back (unlike promote/hotfix). Auto-rollback parity is intentionally +// not emitted here. func (g *ReleaseGenerator) writeJobs(sb *strings.Builder) { sb.WriteString("jobs:\n") g.writePreflightJob(sb) diff --git a/internal/generate/release_test.go b/internal/generate/release_test.go index d5ea047c..0ed929b1 100644 --- a/internal/generate/release_test.go +++ b/internal/generate/release_test.go @@ -9,6 +9,27 @@ import ( "github.com/stretchr/testify/require" ) +// TestReleaseGenerator_NoRollbackJob documents the rollback-parity decision: the +// release workflow tags, generates the changelog, and publishes a GitHub release. +// It performs no environment deploy, so there is no deploy to auto-roll-back +// (unlike promote/hotfix). The generated workflow must therefore name no rollback +// job at all. +func TestReleaseGenerator_NoRollbackJob(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"prod"}, + } + + gen := NewReleaseGenerator(cfg, "") + content, err := gen.Generate() + require.NoError(t, err) + + for _, line := range strings.Split(content, "\n") { + assert.NotContains(t, strings.ToLower(line), "rollback", + "release workflow must not emit any rollback job or step") + } +} + func TestReleaseGenerator_Generate(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main",