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
14 changes: 14 additions & 0 deletions e2e/scenarios/hotfix/hotfix-conflict-resolution.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 32 additions & 4 deletions internal/generate/hotfix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<env> integration branch, opening a resolution pull request, and then
Expand Down Expand Up @@ -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"},
}
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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')"
}

Expand Down
30 changes: 30 additions & 0 deletions internal/generate/hotfix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions internal/hotfix/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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),
}
}

Expand Down
Loading