diff --git a/e2e/scenarios/hotfix/hotfix-conflict-resolution.yaml b/e2e/scenarios/hotfix/hotfix-conflict-resolution.yaml index 22f5e7bb..6a68575a 100644 --- a/e2e/scenarios/hotfix/hotfix-conflict-resolution.yaml +++ b/e2e/scenarios/hotfix/hotfix-conflict-resolution.yaml @@ -131,6 +131,20 @@ steps: # head and replays the check, so the resolution path stays covered. prs: open_with_label: "cascade-hotfix" + # The gitea harness creates PR labels on demand, so it cannot reproduce the + # real-GitHub behavior where "gh pr create --label X" fails hard when X does + # not yet exist. That gap let a missing label seed ship undetected: the + # generated apply job opened the conflict PR with --label + # cascade-hotfix-conflict but never created that label. Assert against the + # materialized workflow that real GitHub runs verbatim: the apply job must + # seed both labels before any PR is opened, and the workflow must request + # issues:write so "gh label create" is authorized. + workflow_files: + - path: ".github/workflows/cascade-hotfix.yaml" + contains: + - "gh label create cascade-hotfix " + - "gh label create cascade-hotfix-conflict " + - "issues: write" - name: "Resolve conflict with patched content for Version 1 base" action: resolve_conflict diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index dae8c180..48de1bd6 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -8,6 +8,20 @@ import ( "github.com/stablekernel/cascade/internal/config" ) +// hotfixLabel is the GitHub label applied to clean hotfix resolution PRs. +// +// The literal must stay in sync with hotfixPRLabel in internal/hotfix/plan.go: +// the planner's protection suggestions seed the same label, and the two live in +// separate packages without a shared constant. +const hotfixLabel = "cascade-hotfix" + +// hotfixConflictLabel is the GitHub label applied to hotfix resolution PRs +// that require manual conflict resolution. +// +// The literal must stay in sync with hotfixConflictPRLabel in +// internal/hotfix/plan.go. +const hotfixConflictLabel = "cascade-hotfix-conflict" + // HotfixGenerator emits the cascade-hotfix workflow. It cherry-picks a trunk fix // onto a diverged intermediate environment by replaying the commit on an // env/ integration branch, opening a resolution pull request, and then @@ -138,14 +152,16 @@ func (g *HotfixGenerator) writeTriggers(sb *strings.Builder) { } // writePermissions grants the scopes the hotfix workflow needs: contents:write -// to push the cherry-pick branch, pull-requests:write to open the resolution PR, -// and actions:read for workflow introspection. +// to push the cherry-pick branch, issues:write to seed labels via gh label create +// (required before gh pr create --label), pull-requests:write to open the +// resolution PR, and actions:read for workflow introspection. func (g *HotfixGenerator) writePermissions(sb *strings.Builder) { // Base scopes the hotfix workflow needs. A reusable callback cannot set its // own job permissions, so any scope a callback declares (e.g. id-token: write // for OIDC) is unioned in at the top level here. base := [][2]string{ {"contents", "write"}, + {"issues", "write"}, {"pull-requests", "write"}, {"actions", "read"}, } @@ -264,6 +280,16 @@ func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) { sb.WriteString(" echo \"::warning::Configure protection: gh api \\\"$PROT_PATH\\\" -X PUT -f required_status_checks.strict=true -F required_status_checks.contexts[]=hotfix-check\"\n") sb.WriteString(" fi\n") + // Seed both resolution-PR labels before any PR is opened. gh pr create + // --label fails hard on a missing label; seeding here guarantees both the + // clean and conflict PR paths can open. The `|| true` keeps the step green on + // a repeated run where the label already exists, matching the seed command + // the planner surfaces (protectionSuggestions in internal/hotfix/plan.go). + sb.WriteString(" - name: Ensure hotfix labels exist\n") + sb.WriteString(" run: |\n") + fmt.Fprintf(sb, " gh label create %s --color B60205 --description \"Cascade hotfix resolution PR\" || true\n", hotfixLabel) + fmt.Fprintf(sb, " gh label create %s --color D93F0B --description \"Cascade hotfix resolution PR with cherry-pick conflicts\" || true\n", hotfixConflictLabel) + // Cherry-pick. Clean and conflict paths diverge after the cherry-pick result. sb.WriteString(" - name: Cherry-pick and open resolution PR\n") sb.WriteString(" run: |\n") @@ -287,7 +313,7 @@ func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) { sb.WriteString(" gh pr create \\\n") sb.WriteString(" --base \"env/${TARGET_ENV}\" \\\n") sb.WriteString(" --head \"$BRANCH\" \\\n") - sb.WriteString(" --label cascade-hotfix \\\n") + fmt.Fprintf(sb, " --label %s \\\n", hotfixLabel) sb.WriteString(" --title \"hotfix(${TARGET_ENV}): cherry-pick ${SHORT_SHA}\" \\\n") sb.WriteString(" --body \"$BODY\"\n") sb.WriteString(" gh pr merge --auto --squash \"$BRANCH\"\n") @@ -301,7 +327,7 @@ func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) { sb.WriteString(" gh pr create \\\n") sb.WriteString(" --base \"env/${TARGET_ENV}\" \\\n") sb.WriteString(" --head \"$BRANCH\" \\\n") - sb.WriteString(" --label cascade-hotfix-conflict \\\n") + fmt.Fprintf(sb, " --label %s \\\n", hotfixConflictLabel) sb.WriteString(" --title \"hotfix(${TARGET_ENV}): cherry-pick ${SHORT_SHA} (conflicts)\" \\\n") sb.WriteString(" --body \"$CONFLICT_BODY\"\n") sb.WriteString(" fi\n") @@ -346,6 +372,7 @@ func (g *HotfixGenerator) writeCheckJob(sb *strings.Builder) { func (g *HotfixGenerator) writeContextJob(sb *strings.Builder) { sb.WriteString(" context:\n") sb.WriteString(" name: Hotfix Context\n") + // The 'cascade-hotfix' literal must match hotfixLabel. sb.WriteString(" if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix')\n") sb.WriteString(" runs-on: ubuntu-latest\n") sb.WriteString(" outputs:\n") @@ -393,6 +420,7 @@ func (g *HotfixGenerator) writeContextJob(sb *strings.Builder) { // mergedHotfixGuard is the if-condition gating the post-merge stages: the PR // merged and carried the cascade-hotfix label. func mergedHotfixGuard() string { + // The 'cascade-hotfix' literal must match hotfixLabel. return "github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix')" } diff --git a/internal/generate/hotfix_test.go b/internal/generate/hotfix_test.go index d303e22a..f44e899d 100644 --- a/internal/generate/hotfix_test.go +++ b/internal/generate/hotfix_test.go @@ -104,6 +104,11 @@ func TestHotfixGenerator_Permissions(t *testing.T) { assert.Contains(t, content, "contents: write") assert.Contains(t, content, "pull-requests: write") assert.Contains(t, content, "actions: read") + // issues: write is required so the apply job can call `gh label create` to + // seed the cascade-hotfix and cascade-hotfix-conflict labels. Without it + // GitHub returns HTTP 403, the || true swallows it silently, and the + // subsequent `gh pr create --label` hard-fails. + assert.Contains(t, content, "issues: write") } func TestHotfixGenerator_Jobs(t *testing.T) { @@ -148,6 +153,31 @@ func TestHotfixGenerator_CleanPath(t *testing.T) { assert.Contains(t, content, "gh pr merge --auto") } +// TestHotfixGenerator_SeedsLabels guards the regression where the apply job ran +// `gh pr create --label cascade-hotfix[-conflict]` without ever creating those +// labels. `gh pr create --label X` hard-fails when label X does not exist, so +// both the clean and conflict resolution PR paths broke. The apply job must seed +// both labels before the cherry-pick step opens any PR. +func TestHotfixGenerator_SeedsLabels(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + // Both labels the resolution PRs reference must be created in the workflow. + assert.Contains(t, content, "gh label create cascade-hotfix ", + "apply job must seed the clean-path label so gh pr create --label does not hard-fail") + assert.Contains(t, content, "gh label create cascade-hotfix-conflict ", + "apply job must seed the conflict-path label so gh pr create --label does not hard-fail") + + // The seed must run before the cherry-pick step that opens the labeled PRs. + seedIdx := strings.Index(content, "gh label create cascade-hotfix ") + cherryPickIdx := strings.Index(content, "Cherry-pick and open resolution PR") + require.NotEqual(t, -1, seedIdx, "label seed step must be present") + require.NotEqual(t, -1, cherryPickIdx, "cherry-pick step must be present") + assert.Less(t, seedIdx, cherryPickIdx, + "the label seed step must appear before the cherry-pick/open-PR step") +} + func TestHotfixGenerator_Q2BranchProtectionWarn(t *testing.T) { gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") content, err := gen.Generate() diff --git a/internal/hotfix/plan.go b/internal/hotfix/plan.go index b4ae8723..c7ab23d8 100644 --- a/internal/hotfix/plan.go +++ b/internal/hotfix/plan.go @@ -20,8 +20,20 @@ import ( const defaultRemote = "origin" // hotfixPRLabel is the label that identifies an in-flight hotfix resolution PR. +// +// The literal must stay in sync with hotfixLabel in +// internal/generate/hotfix.go, which seeds and applies the same label in the +// generated workflow. The two live in separate packages without a shared +// constant. const hotfixPRLabel = "cascade-hotfix" +// hotfixConflictPRLabel is the label applied to a hotfix resolution PR that +// carries cherry-pick conflicts for a human to resolve. +// +// The literal must stay in sync with hotfixConflictLabel in +// internal/generate/hotfix.go. +const hotfixConflictPRLabel = "cascade-hotfix-conflict" + // OpenPR is a minimal view of an open pull request returned by a PRChecker. type OpenPR struct { Number int `json:"number"` @@ -360,6 +372,7 @@ func protectionSuggestions(branch string) []string { "-F required_status_checks=null "+ "-F restrictions=null", branch), fmt.Sprintf("gh label create %s --color B60205 --description \"Cascade hotfix resolution PR\" || true", hotfixPRLabel), + fmt.Sprintf("gh label create %s --color D93F0B --description \"Cascade hotfix resolution PR with cherry-pick conflicts\" || true", hotfixConflictPRLabel), } }