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
55 changes: 55 additions & 0 deletions e2e/scenarios/hotfix/hotfix-rollback-runtime.yaml
Original file line number Diff line number Diff line change
@@ -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=\""
23 changes: 22 additions & 1 deletion internal/generate/hotfix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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.<env>.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")
}

Expand Down Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions internal/generate/hotfix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<deploy> 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.<env>.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 <subcommand> --flag`
// pairing parsed from the generated workflow, for cross-checking against the
// real cobra command tree.
Expand Down
4 changes: 4 additions & 0 deletions internal/generate/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions internal/generate/release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading