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: 10 additions & 4 deletions internal/generate/hotfix.go
Original file line number Diff line number Diff line change
Expand Up @@ -673,10 +673,16 @@ func (g *HotfixGenerator) writeFinalizeJob(sb *strings.Builder) {
sb.WriteString(" - name: Finalize hotfix\n")
sb.WriteString(" env:\n")
// GH_TOKEN authenticates the Contents REST API write that finalize performs
// on real GitHub (signed commit, branch-protection bypass). GITHUB_TOKEN
// authenticates the release/tag API calls. GITHUB_REPOSITORY names the target
// repo for both.
sb.WriteString(" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n")
// against the protected trunk manifest. It must carry the configured state
// token so the write bypasses trunk's require-pull-request rule
// (enforce_admins=false), mirroring the promote and orchestrate finalize state
// writes; the default GITHUB_TOKEN is github-actions[bot], which trunk
// protection rejects with a 409. It defaults to GITHUB_TOKEN when no state
// token is configured, so a repo with a protected trunk must supply a
// bypass-capable state_token for finalize to record state there. GITHUB_TOKEN
// authenticates the release/tag API calls, which are not gated by branch
// protection. GITHUB_REPOSITORY names the target repo for both.
fmt.Fprintf(sb, " GH_TOKEN: %s\n", g.getStateTokenRef())
sb.WriteString(" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n")
sb.WriteString(" GITHUB_REPOSITORY: ${{ github.repository }}\n")
sb.WriteString(" run: |\n")
Expand Down
43 changes: 43 additions & 0 deletions internal/generate/hotfix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,49 @@ func TestHotfixGenerator_ApplySkippedOnNoOp(t *testing.T) {
"apply job must skip when the plan reports a no-op")
}

// TestHotfixGenerator_FinalizeWritesStateWithStateToken guards the trunk
// branch-protection block on the finalize state write. The finalize step runs
// `cascade hotfix finalize`, which performs a Contents REST API write to the
// trunk manifest. Authored by the default GITHUB_TOKEN (github-actions[bot]) that
// write is rejected by trunk's require-pull-request rule with a 409. The state
// token is an admin PAT configured to bypass that rule (enforce_admins=false),
// exactly as the promote and orchestrate finalize state writes do. The step's
// GH_TOKEN must therefore carry the configured state token, not bare
// GITHUB_TOKEN.
func TestHotfixGenerator_FinalizeWritesStateWithStateToken(t *testing.T) {
cfg := threeEnvHotfixConfig()
cfg.StateToken = "${{ secrets.CASCADE_BOT_TOKEN }}"
gen := NewHotfixGenerator(cfg, "")
content, err := gen.Generate()
require.NoError(t, err)

finalizeJob := extractJobSection(t, content, "finalize:")
require.NotEmpty(t, finalizeJob, "finalize job section should be present")

// The Contents API write that finalize performs against the protected trunk
// must authenticate with the trigger-capable state token, mirroring promote.
assert.Contains(t, finalizeJob, "GH_TOKEN: ${{ secrets.CASCADE_BOT_TOKEN }}",
"finalize must write trunk state with the state token so it bypasses trunk's require-PR protection; bare GITHUB_TOKEN is blocked with a 409")
assert.NotContains(t, finalizeJob, "GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
"finalize GH_TOKEN must not be the bot GITHUB_TOKEN when a state token is configured")
}

// TestHotfixGenerator_FinalizeStateTokenDefaultsToGitHubToken confirms back-compat:
// with no state token configured the finalize step's GH_TOKEN degrades to the
// default GITHUB_TOKEN expression, matching the token plumbing used by the apply,
// merge, promote, and orchestrate state writes. A repo with a protected trunk
// must configure a bypass-capable state_token for finalize to succeed there.
func TestHotfixGenerator_FinalizeStateTokenDefaultsToGitHubToken(t *testing.T) {
gen := NewHotfixGenerator(threeEnvHotfixConfig(), "")
content, err := gen.Generate()
require.NoError(t, err)

finalizeJob := extractJobSection(t, content, "finalize:")
require.NotEmpty(t, finalizeJob, "finalize job section should be present")
assert.Contains(t, finalizeJob, "GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
"with no state token configured the finalize GH_TOKEN must fall back to GITHUB_TOKEN")
}

// TestHotfixGenerator_FinalizeFetchesEnvBranches guards that the finalize job
// fetches the env/* branches before running the verb. finalize cross-checks the
// merge SHA against the env-branch tip; without the fetch the branch is absent
Expand Down
Loading