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
38 changes: 25 additions & 13 deletions e2e/harness/hotfix_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,19 @@ func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep)
return nil
}

commit := r.resolveCommit(step.CommitRef)
// CommitRef may be a comma-delimited set so a single apply (and the single
// finalize that follows it) carries a multi-commit hotfix, mirroring the
// product workflow's per-env $COMMITS cherry-pick loop. resolveCommits maps each
// scenario ref to its SHA and rejoins with commas; a single ref resolves
// identically to resolveCommit, keeping single-commit scenarios stable.
commitList := r.resolveCommits(step.CommitRef)
commits := strings.Split(commitList, ",")
commit := commits[0]
env := step.TargetEnv
envBranch := "env/" + env
short := shortSHA(commit)
hotfixBranch := "hotfix/" + env + "/" + short
r.t.Logf(" HotfixApply: commit=%s env=%s branch=%s", truncateSHA(commit), env, hotfixBranch)
r.t.Logf(" HotfixApply: commits=%s env=%s branch=%s", commitList, env, hotfixBranch)

// Ensure env/<env> exists, anchored at the env's recorded state SHA (or HEAD).
branches, err := r.harness.gitea.ListBranches(ctx, r.harness.repo)
Expand Down Expand Up @@ -205,16 +212,21 @@ func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep)
"git fetch origin '+refs/heads/*:refs/remotes/origin/*' --tags >/dev/null 2>&1",
fmt.Sprintf("git branch -D %q >/dev/null 2>&1 || true", hotfixBranch),
fmt.Sprintf("git switch -c %q %q", hotfixBranch, "origin/"+envBranch),
fmt.Sprintf("git cherry-pick -x %q", commit),
"CP_EXIT=$?",
"if [ \"$CP_EXIT\" -ne 0 ]; then",
" CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\\n' ' ')",
" echo \"CONFLICT_FILES=$CONFLICTS\"",
" git add -A",
fmt.Sprintf(" git -c core.editor=true cherry-pick --continue || git commit -m %q", "hotfix: cherry-pick "+short8+" with conflicts"),
"else",
" echo \"CONFLICT_FILES=\"",
"fi",
// Cherry-pick every commit in the set onto the hotfix branch, mirroring the
// product apply loop. On the first conflict, classify and force-commit the
// partial resolution, then stop; clean sets apply all commits.
"CONFLICT_FILES=",
fmt.Sprintf("for commit in %s; do", strings.Join(commits, " ")),
" git cherry-pick -x \"$commit\"",
" CP_EXIT=$?",
" if [ \"$CP_EXIT\" -ne 0 ]; then",
" CONFLICT_FILES=$(git diff --name-only --diff-filter=U | tr '\\n' ' ')",
" git add -A",
fmt.Sprintf(" git -c core.editor=true cherry-pick --continue || git commit -m %q", "hotfix: cherry-pick "+short8+" with conflicts"),
" break",
" fi",
"done",
"echo \"CONFLICT_FILES=$CONFLICT_FILES\"",
// Force-push the uniquely-named, per-apply throwaway hotfix branch. The
// branch name embeds the source short SHA, so a force-push only ever
// overwrites this apply's own prior attempt (e.g. a retried sync), never a
Expand Down Expand Up @@ -242,7 +254,7 @@ func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep)

// Build the PR body with the three product trailers; append the conflict file
// list on the conflict path.
body := fmt.Sprintf("Cascade-Hotfix-Target: %s\nCascade-Hotfix-Source: %s\nCascade-Hotfix-Base: %s\n", env, commit, baseSHA)
body := fmt.Sprintf("Cascade-Hotfix-Target: %s\nCascade-Hotfix-Source: %s\nCascade-Hotfix-Base: %s\n", env, commitList, baseSHA)
title := fmt.Sprintf("hotfix(%s): cherry-pick %s", env, short)
label := "cascade-hotfix"
if conflict {
Expand Down
102 changes: 102 additions & 0 deletions e2e/scenarios/hotfix/hotfix-multi-commit-clean.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: "Hotfix Multi-Commit Single Cycle"
description: |
Verifies that a single hotfix apply-merge-finalize cycle carrying a SET of trunk
commits records every commit in state.<env>.patches, not just the first.

This is the multi-commit-per-finalize case the multi-env-clean scenario does not
reach: that scenario applies one commit per cycle, so each finalize records a
single patch. Here one apply cherry-picks both commit2 and commit3 onto env/test
in a single PR; the apply stamps the full comma-joined set in the
Cascade-Hotfix-Source trailer, and the single finalize must append both to the
patch set.

config:
trunk_branch: main
environments: [dev, test, staging, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
- name: deploy-dev
workflow: deploy.yaml
triggers: ["src/**"]
- name: deploy-test
workflow: deploy.yaml
triggers: ["src/**"]
- name: deploy-staging
workflow: deploy.yaml
triggers: ["src/**"]
- name: deploy-prod
workflow: deploy.yaml
triggers: ["src/**"]

steps:
- name: "Initial commit"
action: commit
commit:
message: "feat: add app"
files:
src/app.go: |
package main
func main() {}

- name: "Orchestrate trunk into dev"
action: orchestrate

- name: "Promote to establish test/staging/prod baselines"
action: promote
promote:
mode: default

- name: "Promote again to carry the baseline down the chain"
action: promote
promote:
mode: default

- name: "Promote once more so every env shares the baseline"
action: promote
promote:
mode: default

- name: "First trunk fix"
action: commit
commit:
message: "fix: first patch"
files:
src/fix1.go: |
package main
func fix1() {}

- name: "Second trunk fix"
action: commit
commit:
message: "fix: second patch"
files:
src/fix2.go: |
package main
func fix2() {}

# One apply cherry-picks both fixes onto env/test in a single PR, stamping the
# full comma-joined commit set in the Cascade-Hotfix-Source trailer.
- name: "Apply both fixes onto env/test in one cycle"
action: hotfix_apply
hotfix_apply:
target_env: test
commit_ref: commit2,commit3

- name: "Merge the multi-commit test PR"
action: merge_pr
merge_pr:
label: cascade-hotfix

# A single finalize must record BOTH commits, not just commit2.
- name: "Finalize; test carries both patches from one cycle"
action: hotfix_merged
hotfix_merged:
target_env: test
expect:
state:
test:
ref: "env/test"
patches: [commit2, commit3]
18 changes: 11 additions & 7 deletions internal/generate/hotfix.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,9 +390,11 @@ func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) {
sb.WriteString(" git fetch origin \"+refs/heads/env/${env}:refs/remotes/origin/env/${env}\"\n")
sb.WriteString(" fi\n")
sb.WriteString(" git switch -c \"$BRANCH\" \"$BASE\"\n")
// The PR-body trailers carry the first applied commit and the base anchor so
// the post-merge context job can recover the fix and base SHAs.
sb.WriteString(" BODY=$(printf 'Cascade-Hotfix-Target: %s\\nCascade-Hotfix-Source: %s\\nCascade-Hotfix-Base: %s\\n' \"$env\" \"$FIRST_COMMIT\" \"$BASE\")\n")
// The PR-body trailers carry the full comma-joined set of applied trunk
// commits and the base anchor so the post-merge context job can recover every
// fix SHA (not just the first) and the base SHA. The Source trailer mirrors the
// per-env $COMMITS list the cherry-pick loop below applies.
sb.WriteString(" BODY=$(printf 'Cascade-Hotfix-Target: %s\\nCascade-Hotfix-Source: %s\\nCascade-Hotfix-Base: %s\\n' \"$env\" \"$COMMITS\" \"$BASE\")\n")
sb.WriteString(" CLEAN=true\n")
sb.WriteString(" CONFLICT_COMMIT=\"\"\n")
sb.WriteString(" CONFLICTS=\"\"\n")
Expand Down Expand Up @@ -518,10 +520,12 @@ func (g *HotfixGenerator) writeContextJob(sb *strings.Builder) {
sb.WriteString(" PR_BODY: ${{ github.event.pull_request.body }}\n")
sb.WriteString(" run: |\n")
sb.WriteString(" TARGET_ENV=\"${BASE_REF#env/}\"\n")
// Recover the trunk fix commit and the trunk base anchor from the trailers
// the apply job stamped into the resolution PR body. grep tolerates absent
// trailers (the || true) so the step never hard-fails here; the finalize
// command enforces that the required SHAs are present.
// Recover the full comma-joined set of trunk fix commits and the trunk base
// anchor from the trailers the apply job stamped into the resolution PR body.
// The Source trailer carries every applied commit, so keeping the whole value
// (rather than the first field) threads the complete set to finalize. grep
// tolerates absent trailers (the || true) so the step never hard-fails here;
// the finalize 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
Expand Down
27 changes: 27 additions & 0 deletions internal/generate/hotfix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,33 @@ func TestHotfixGenerator_ConflictPath(t *testing.T) {
assert.Contains(t, content, "Cascade-Hotfix-Base:")
}

// TestHotfixGenerator_SourceTrailerCarriesFullCommitSet guards the multi-commit
// patch-accumulation fix: the resolution PR's Cascade-Hotfix-Source trailer must
// carry the whole per-env $COMMITS set so the post-merge finalize records every
// cherry-picked commit, not just the first. Stamping only the first commit (the
// old $FIRST_COMMIT trailer) dropped the rest from state.<env>.patches.
func TestHotfixGenerator_SourceTrailerCarriesFullCommitSet(t *testing.T) {
gen := NewHotfixGenerator(threeEnvHotfixConfig(), "")
content, err := gen.Generate()
require.NoError(t, err)

// The trailer interpolates the full comma-joined per-env commit set.
assert.Contains(t, content, `Cascade-Hotfix-Source: %s\n`,
"the Source trailer must be a printf field")
assert.Contains(t, content, `"$env" "$COMMITS" "$BASE"`,
"the Source trailer must carry the full per-env $COMMITS set, not just the first commit")
assert.NotContains(t, content, `"$env" "$FIRST_COMMIT" "$BASE"`,
"the Source trailer must not stamp only the first commit")

// The context job recovers the whole Source value (it strips the prefix and
// keeps the comma-joined remainder) and the finalize job passes it straight
// to --fix-sha, which the command splits back into the full set.
assert.Contains(t, content, `sed 's/^Cascade-Hotfix-Source:[[:space:]]*//'`,
"the context job must keep the whole Source value, not a single field")
assert.Contains(t, content, `--fix-sha "$FIX_SHA"`,
"the finalize job must forward the recovered (possibly comma-joined) fix-sha set")
}

func TestHotfixGenerator_CleanPath(t *testing.T) {
gen := NewHotfixGenerator(threeEnvHotfixConfig(), "")
content, err := gen.Generate()
Expand Down
9 changes: 7 additions & 2 deletions internal/hotfix/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,20 @@ records the merge SHA is a no-op.`,
finalizer.SetBuildResult(name, result)
}

return finalizer.Finalize(targetEnv, mergeSHA, fixSHA, baseSHA)
fixSHAs, err := parseCommitRefs(fixSHA)
if err != nil {
return fmt.Errorf("invalid --fix-sha %q: %w", fixSHA, err)
}

return finalizer.Finalize(targetEnv, mergeSHA, fixSHAs, baseSHA)
},
}

cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to manifest file (default: .github/manifest.yaml)")
cmd.Flags().StringVar(&manifestKey, "key", config.DefaultManifestKey, "Top-level manifest key")
cmd.Flags().StringVar(&targetEnv, "target-env", "", "Environment to finalize (required)")
cmd.Flags().StringVar(&mergeSHA, "merge-sha", "", "Tip of env/<target> after the resolution PR merged (required)")
cmd.Flags().StringVar(&fixSHA, "fix-sha", "", "Trunk commit the hotfix carries (required)")
cmd.Flags().StringVar(&fixSHA, "fix-sha", "", "Trunk commit(s) the hotfix carries; comma-delimited for a multi-commit set (required)")
cmd.Flags().StringVar(&baseSHA, "base-sha", "", "Trunk anchor the integration branch diverged from (required)")
cmd.Flags().StringVar(&actor, "actor", "", "Actor recorded on the state (default: $GITHUB_ACTOR)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate and compute without writing state, tags, or releases")
Expand Down
27 changes: 17 additions & 10 deletions internal/hotfix/finalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,17 +342,23 @@ func (f *Finalizer) SetBuildResult(name, result string) {
// Finalize writes the diverged state for a completed hotfix on env/<targetEnv>.
//
// targetEnv is the environment being hotfixed; mergeSHA is the tip of
// env/<targetEnv> after the resolution PR merged; fixSHA is the trunk commit the
// hotfix carries; baseSHA is the trunk anchor the integration branch diverged
// from. It cross-checks the merge SHA against the env-branch tip, allocates the
// next free hotfix version, snapshots the prior state into the Previous ring,
// writes the divergence fields and substates, commits the manifest to trunk, and
// creates the hotfix tag and release object.
// env/<targetEnv> after the resolution PR merged; fixSHAs are the trunk commits
// the hotfix carries (every cherry-picked commit, in apply order); baseSHA is the
// trunk anchor the integration branch diverged from. It cross-checks the merge
// SHA against the env-branch tip, allocates the next free hotfix version,
// snapshots the prior state into the Previous ring, writes the divergence fields
// and substates, commits the manifest to trunk, and creates the hotfix tag and
// release object. Every commit in fixSHAs is appended to the env's recorded
// patch set so a multi-commit hotfix records all of its commits, not just the
// first.
//
// Finalize is idempotent on identical inputs: a rerun after the state already
// records the merge SHA is a no-op that neither double-applies patches nor
// re-snapshots Previous.
func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error {
func (f *Finalizer) Finalize(targetEnv, mergeSHA string, fixSHAs []string, baseSHA string) error {
if len(fixSHAs) == 0 {
return fmt.Errorf("no fix commits supplied; finalize needs at least one trunk commit")
}
cfg := f.cicd.Config
if cfg == nil {
return fmt.Errorf("manifest has no config block")
Expand Down Expand Up @@ -437,7 +443,7 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error
if prior.BaseSHA == "" {
prior.BaseSHA = baseSHA
}
prior.Patches = append(prior.Patches, fixSHA)
prior.Patches = append(prior.Patches, fixSHAs...)
prior.Ref = branch
prior.SHA = mergeSHA
prior.Version = hotfixVersion
Expand All @@ -456,8 +462,9 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error
return fmt.Errorf("committing hotfix state: %w", err)
}

// Create the hotfix tag and release object.
if err := f.createRelease(cfg, targetEnv, mergeSHA, hotfixVersion, fixSHA, baseVersion); err != nil {
// Create the hotfix tag and release object. The release body references the
// first carried commit as the representative fix SHA.
if err := f.createRelease(cfg, targetEnv, mergeSHA, hotfixVersion, fixSHAs[0], baseVersion); err != nil {
return err
}

Expand Down
2 changes: 1 addition & 1 deletion internal/hotfix/finalize_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func TestFinalize_Integration_PlanThenMergeThenFinalize(t *testing.T) {
f.SetDeployResult("api", "success")
f.SetBuildResult("api", "success")

if err := f.Finalize("test", mergeSHA, fix, base); err != nil {
if err := f.Finalize("test", mergeSHA, []string{fix}, base); err != nil {
t.Fatalf("finalize: %v", err)
}

Expand Down
Loading
Loading