From 37d3252966cc81e81ae028e43684c81300386530 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 10 Jun 2026 09:49:34 -0400 Subject: [PATCH] fix: write finalize state through GitHub API to satisfy protected trunk The generated finalize jobs persisted manifest state with git push to the trunk branch. With branch protection requiring a status check, that push is rejected, and the bot commits are unsigned. On real GitHub the orchestrate, release, and promote finalize paths now write the manifest through the Contents REST API, which produces a verified, signed commit and, with a bypass-capable token, updates a protected trunk. The act/gitea e2e path keeps the existing git push. A new optional state_token config (default GITHUB_TOKEN) supplies the API token. Signed-off-by: Joshua Temple --- internal/config/types.go | 15 +++ internal/config/types_test.go | 10 ++ internal/generate/generator.go | 49 ++++---- internal/generate/promote.go | 12 ++ internal/generate/release.go | 47 ++++---- internal/generate/state_write.go | 150 ++++++++++++++++++++++++ internal/generate/state_write_test.go | 160 ++++++++++++++++++++++++++ internal/promote/finalize.go | 97 ++++++++++++++-- internal/promote/finalize_test.go | 25 ++++ 9 files changed, 503 insertions(+), 62 deletions(-) create mode 100644 internal/generate/state_write.go create mode 100644 internal/generate/state_write_test.go diff --git a/internal/config/types.go b/internal/config/types.go index dc977427..67901faa 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -98,6 +98,7 @@ type TrunkConfig struct { CLIVersion string `yaml:"cli_version,omitempty" json:"cli_version,omitempty"` // cascade CLI version (e.g., v1.0.0) TagPrefix string `yaml:"tag_prefix,omitempty" json:"tag_prefix,omitempty"` // Version tag prefix (default: "v") ReleaseToken string `yaml:"release_token,omitempty" json:"release_token,omitempty"` // GitHub secret name for release operations (default: "GITHUB_TOKEN") + StateToken string `yaml:"state_token,omitempty" json:"state_token,omitempty"` // Token expression for writing manifest state to the trunk branch (default: "GITHUB_TOKEN") ManifestFile string `yaml:"manifest_file,omitempty" json:"manifest_file,omitempty"` // Config file path (default: ".github/manifest.yaml") ManifestKey string `yaml:"manifest_key,omitempty" json:"manifest_key,omitempty"` // Nested key in manifest file (default: "ci") ActionFolder string `yaml:"action_folder,omitempty" json:"action_folder,omitempty"` // Folder name for manage-release action (default: "manage-release") @@ -255,6 +256,20 @@ func (c *TrunkConfig) GetReleaseToken() string { return c.ReleaseToken } +// GetStateToken returns the configured state-write token expression or +// "${{ secrets.GITHUB_TOKEN }}" if not specified. This token is used to write +// the manifest state back to the trunk branch. On real GitHub the write goes +// through the REST API, so a token with permission to bypass branch protection +// (for example a GitHub App or bot token) can be supplied here to update a +// protected trunk and produce a verified, signed commit. +// Users should provide the full GitHub Actions expression, e.g. "${{ secrets.MY_TOKEN }}". +func (c *TrunkConfig) GetStateToken() string { + if c.StateToken == "" { + return "${{ secrets.GITHUB_TOKEN }}" + } + return c.StateToken +} + // GetManifestFile returns the configured manifest file path or ".github/manifest.yaml" if not specified func (c *TrunkConfig) GetManifestFile() string { if c.ManifestFile == "" { diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 453b8923..4a19ce3c 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -420,6 +420,16 @@ func TestGetReleaseToken(t *testing.T) { assert.Equal(t, "${{ secrets.CUSTOM_RELEASE_TOKEN }}", cfg.GetReleaseToken()) } +func TestGetStateToken(t *testing.T) { + // Default when not set + cfg := &TrunkConfig{} + assert.Equal(t, "${{ secrets.GITHUB_TOKEN }}", cfg.GetStateToken()) + + // Configured value (full expression) + cfg.StateToken = "${{ secrets.CASCADE_BOT_TOKEN }}" + assert.Equal(t, "${{ secrets.CASCADE_BOT_TOKEN }}", cfg.GetStateToken()) +} + func TestGetGitMode(t *testing.T) { // Default when no git config cfg := &TrunkConfig{} diff --git a/internal/generate/generator.go b/internal/generate/generator.go index b9435afb..a60b89ce 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -101,6 +101,13 @@ func NewGenerator(cfg *config.TrunkConfig, baseDir string) *Generator { } } +// getStateTokenRef returns the token expression used to write manifest state to +// the trunk branch. Users configure the full expression via the state_token +// config option; it defaults to "${{ secrets.GITHUB_TOKEN }}". +func (g *Generator) getStateTokenRef() string { + return g.config.GetStateToken() +} + // ownedJobTimeoutMinutes returns the timeout-minutes to emit on cascade-owned // jobs: the manifest's config.job_timeout_minutes when set (>0), otherwise // DefaultJobTimeoutMinutes. Reusable-workflow callbacks (jobs..uses) own @@ -1418,6 +1425,7 @@ func (g *Generator) writeSummaryStep(sb *strings.Builder, sorted []string) { func (g *Generator) writeManifestUpdateStep(sb *strings.Builder, sorted []string) { sb.WriteString(" - name: Update Manifest\n") sb.WriteString(" env:\n") + fmt.Fprintf(sb, " GH_TOKEN: %s\n", g.getStateTokenRef()) sb.WriteString(" HEAD_SHA: ${{ needs.setup.outputs.head_sha }}\n") sb.WriteString(" VERSION: ${{ needs.setup.outputs.version }}\n") @@ -1523,33 +1531,22 @@ func (g *Generator) writeManifestUpdateStep(sb *strings.Builder, sorted []string sb.WriteString(" }\n") sb.WriteString(" \n") - // Retry loop: fetch + reset + reapply + commit + push, up to 5 attempts. - // Concurrent orchestrate runs racing to push state used to fail with - // non-fast-forward; this loop keeps the slower run trying with a fresh - // commit on the latest tip. - sb.WriteString(" for attempt in 1 2 3 4 5; do\n") - sb.WriteString(" git fetch origin \"$BRANCH\"\n") - sb.WriteString(" git reset --hard \"origin/$BRANCH\"\n") - sb.WriteString(" apply_state_edits\n") - sb.WriteString(" if git diff --quiet \"$MANIFEST_FILE\"; then\n") - sb.WriteString(" echo \"No state changes\"\n") - sb.WriteString(" exit 0\n") - sb.WriteString(" fi\n") - sb.WriteString(" git add \"$MANIFEST_FILE\"\n") + // Persist the manifest state to the trunk branch. On real GitHub this writes + // through the Contents REST API so the commit is signed (Verified) and can + // bypass branch protection with a capable token; in act/gitea it pushes with + // the existing fetch/reset/reapply/commit/push retry loop. Concurrent + // orchestrate runs racing to write state are handled by retrying on top of + // the latest tip in both paths. + commitMessage := "chore: update state [skip ci]" if len(g.config.Environments) > 0 { - sb.WriteString(" git commit -m \"chore: update state for $ENVIRONMENT [skip ci]\"\n") - } else { - sb.WriteString(" git commit -m \"chore: update state [skip ci]\"\n") - } - sb.WriteString(" if git push origin \"HEAD:$BRANCH\"; then\n") - sb.WriteString(" echo \"Pushed state on attempt $attempt\"\n") - sb.WriteString(" exit 0\n") - sb.WriteString(" fi\n") - sb.WriteString(" echo \"Push attempt $attempt rejected (likely concurrent run); retrying...\" >&2\n") - sb.WriteString(" sleep $((RANDOM % 5 + 2))\n") - sb.WriteString(" done\n") - sb.WriteString(" echo \"::error::Failed to push state after 5 attempts\" >&2\n") - sb.WriteString(" exit 1\n") + commitMessage = "chore: update state for $ENVIRONMENT [skip ci]" + } + writeStateCommitPush(sb, " ", stateWriteParams{ + applyFn: "apply_state_edits", + commitMessage: commitMessage, + noChangeLabel: "No state changes", + successLabel: "Pushed state", + }) } func (g *Generator) writeNotifyPrimaryStep(sb *strings.Builder) { diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 3237de35..7a1fe060 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -62,6 +62,13 @@ func (g *PromoteGenerator) getReleaseTokenRef() string { return g.config.GetReleaseToken() } +// getStateTokenRef returns the token expression used to write manifest state to +// the trunk branch. Users configure the full expression via the state_token +// config option; it defaults to "${{ secrets.GITHUB_TOKEN }}". +func (g *PromoteGenerator) getStateTokenRef() string { + return g.config.GetStateToken() +} + // getManifestFilePath returns the manifest file path for use in generated scripts. // Converts absolute paths to repo-relative paths since workflows run in checked out repos. func (g *PromoteGenerator) getManifestFilePath() string { @@ -1303,6 +1310,11 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" - name: Finalize Promotion\n") sb.WriteString(" if: ${{ github.event.inputs.dry_run != 'true' }}\n") sb.WriteString(" env:\n") + // GH_TOKEN authenticates the Contents REST API write that finalize performs + // on real GitHub (signed commit, branch-protection bypass). It defaults to + // the same token as the release operations but is independently configurable + // via state_token so a bot/App token can be supplied for protected trunks. + fmt.Fprintf(sb, " GH_TOKEN: %s\n", g.getStateTokenRef()) fmt.Fprintf(sb, " GITHUB_TOKEN: %s\n", g.getReleaseTokenRef()) sb.WriteString(" PROMOTION_RESULT: ${{ needs.preflight.outputs.promotion_result }}\n") for _, d := range g.config.Deploys { diff --git a/internal/generate/release.go b/internal/generate/release.go index 63d382f8..fc4c89be 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -45,6 +45,14 @@ func (g *ReleaseGenerator) getReleaseTokenRef() string { return g.config.GetReleaseToken() } +// getStateTokenRef returns the token expression used to write manifest state to +// the trunk branch. Users configure the full expression via the state_token +// config option; it defaults to the release token expression so existing +// manifests keep using a single token. +func (g *ReleaseGenerator) getStateTokenRef() string { + return g.config.GetStateToken() +} + // getManifestFilePath returns the manifest file path for use in generated scripts. // Converts absolute paths to repo-relative paths since workflows run in checked out repos. func (g *ReleaseGenerator) getManifestFilePath() string { @@ -374,6 +382,7 @@ func (g *ReleaseGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" - name: Update Latest Release State\n") sb.WriteString(" if: ${{ github.event.inputs.dry_run != 'true' && needs.release.result == 'success' }}\n") sb.WriteString(" env:\n") + fmt.Fprintf(sb, " GH_TOKEN: %s\n", g.getStateTokenRef()) sb.WriteString(" SEMVER_TAG: ${{ needs.preflight.outputs.semver_tag }}\n") sb.WriteString(" SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}\n") sb.WriteString(" run: |\n") @@ -398,32 +407,20 @@ func (g *ReleaseGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" }\n") sb.WriteString(" \n") - // Same retry-with-rebase pattern as the orchestrate finalize Update - // Manifest step (#101). release.yaml is workflow_dispatch-only so the - // race window is smaller than orchestrate's, but a concurrent - // orchestrate state push can still reject this push as non-fast-forward. - // See #102. + // Persist latest_release state to the trunk branch. On real GitHub this + // writes through the Contents REST API so the commit is signed (Verified) + // and can bypass branch protection with a capable token; in act/gitea it + // pushes with the existing fetch/reset/reapply/commit/push retry loop. + // release.yaml is workflow_dispatch-only so the race window is smaller than + // orchestrate's, but a concurrent orchestrate state write can still collide, + // so both paths retry on top of the latest tip. sb.WriteString(" echo \"Updating latest_release state\"\n") - sb.WriteString(" for attempt in 1 2 3 4 5; do\n") - sb.WriteString(" git fetch origin \"$BRANCH\"\n") - sb.WriteString(" git reset --hard \"origin/$BRANCH\"\n") - sb.WriteString(" apply_release_state_edits\n") - sb.WriteString(" if git diff --quiet \"$MANIFEST_FILE\"; then\n") - sb.WriteString(" echo \"No latest_release state changes\"\n") - sb.WriteString(" exit 0\n") - sb.WriteString(" fi\n") - sb.WriteString(" git add \"$MANIFEST_FILE\"\n") - sb.WriteString(" git commit -m \"chore: update latest_release state\n\n") - sb.WriteString(" Version: $SEMVER_TAG\"\n") - sb.WriteString(" if git push origin \"HEAD:$BRANCH\"; then\n") - sb.WriteString(" echo \"Pushed latest_release state on attempt $attempt\"\n") - sb.WriteString(" exit 0\n") - sb.WriteString(" fi\n") - sb.WriteString(" echo \"Push attempt $attempt rejected (likely concurrent run); retrying...\" >&2\n") - sb.WriteString(" sleep $((RANDOM % 5 + 2))\n") - sb.WriteString(" done\n") - sb.WriteString(" echo \"::error::Failed to push latest_release state after 5 attempts\" >&2\n") - sb.WriteString(" exit 1\n") + writeStateCommitPush(sb, " ", stateWriteParams{ + applyFn: "apply_release_state_edits", + commitMessage: "chore: update latest_release state\n\nVersion: $SEMVER_TAG", + noChangeLabel: "No latest_release state changes", + successLabel: "Pushed latest_release state", + }) // Summary sb.WriteString(" - name: Summary\n") diff --git a/internal/generate/state_write.go b/internal/generate/state_write.go new file mode 100644 index 00000000..758d9cad --- /dev/null +++ b/internal/generate/state_write.go @@ -0,0 +1,150 @@ +package generate + +import ( + "fmt" + "strings" +) + +// stateWriteParams describes a single manifest state-write step. Both the +// orchestrate finalize ("Update Manifest") and release finalize ("Update Latest +// Release State") steps apply yq edits to the manifest and then need to persist +// that change to the trunk branch. The persistence path differs by environment. +type stateWriteParams struct { + // applyFn is the name of the already-defined shell function that re-applies + // the yq edits to $MANIFEST_FILE (for example "apply_state_edits"). It is + // invoked once per attempt so a rebase onto the latest tip can re-layer the + // edits without conflicting on the same yaml line. + applyFn string + // commitMessage is the commit message body for the state commit. It may + // contain shell variable references (for example "$ENVIRONMENT") that are + // already exported in the step env, and may span multiple lines. + commitMessage string + // noChangeLabel is printed when the manifest has no pending changes. + noChangeLabel string + // successLabel is printed after a successful write. + // + // The token used for the REST API write is not part of this struct: it is + // supplied to the gh CLI via the step's GH_TOKEN env (see getStateTokenRef). + // A token able to bypass branch protection lets the write land on a protected + // trunk and produces a verified, signed commit. + successLabel string +} + +// writeStateCommitPush emits the dual-path state-write logic into an already-open +// "run: |" block at the given indent. The caller must have already set the +// MANIFEST_FILE and BRANCH shell variables and defined the apply function named +// by params.applyFn. +// +// On real GitHub the write goes through the Contents REST API: API-created +// commits are signed by GitHub (shown as Verified) and, when made with a +// bypass-capable token, update the trunk even when a required status check +// protects the branch. In the act/gitea e2e environment (detected exactly as +// the dispatch steps do, by GITHUB_SERVER_URL != https://github.com) there is no +// GitHub API, so the existing fetch/reset/reapply/commit/push retry loop runs +// unchanged. gitea enforces neither branch protection nor commit signatures, so +// that path keeps working as before. +func writeStateCommitPush(sb *strings.Builder, indent string, params stateWriteParams) { + // w writes a single indented line. When no format args are supplied the + // string is written verbatim so literal '%' (for example in + // "$((RANDOM % 5 + 2))") is never interpreted as a format verb. + w := func(format string, args ...any) { + if len(args) == 0 { + sb.WriteString(indent + format + "\n") + return + } + fmt.Fprintf(sb, indent+format+"\n", args...) + } + + w("if [[ \"$GITHUB_SERVER_URL\" != \"https://github.com\" ]]; then") + // gitea/act path: keep the existing git fetch/reset/reapply/commit/push loop. + w(" # act/gitea e2e: no GitHub API, and the trunk is neither protected nor") + w(" # signature-checked, so push the state commit directly with retries.") + w(" for attempt in 1 2 3 4 5; do") + w(" git fetch origin \"$BRANCH\"") + w(" git reset --hard \"origin/$BRANCH\"") + w(" %s", params.applyFn) + w(" if git diff --quiet \"$MANIFEST_FILE\"; then") + w(" echo \"%s\"", params.noChangeLabel) + w(" exit 0") + w(" fi") + w(" git add \"$MANIFEST_FILE\"") + writeShellCommit(sb, indent+" ", params.commitMessage) + w(" if git push origin \"HEAD:$BRANCH\"; then") + w(" echo \"%s on attempt $attempt\"", params.successLabel) + w(" exit 0") + w(" fi") + w(" echo \"Push attempt $attempt rejected (likely concurrent run); retrying...\" >&2") + w(" sleep $((RANDOM % 5 + 2))") + w(" done") + w(" echo \"::error::Failed to push state after 5 attempts\" >&2") + w(" exit 1") + w("fi") + w("") + // Real GitHub path: write through the Contents REST API so the commit is + // signed by GitHub and can bypass branch protection with a capable token. + w("# Real GitHub: write state through the Contents REST API. API commits are") + w("# signed by GitHub (Verified) and, with a bypass-capable token, update the") + w("# trunk even when a required status check protects it.") + w("for attempt in 1 2 3 4 5; do") + w(" git fetch origin \"$BRANCH\"") + w(" git reset --hard \"origin/$BRANCH\"") + w(" %s", params.applyFn) + w(" if git diff --quiet \"$MANIFEST_FILE\"; then") + w(" echo \"%s\"", params.noChangeLabel) + w(" exit 0") + w(" fi") + w(" CONTENT_B64=$(base64 -w0 \"$MANIFEST_FILE\" 2>/dev/null || base64 \"$MANIFEST_FILE\" | tr -d '\\n')") + w(" CURRENT_SHA=$(gh api \"repos/${{ github.repository }}/contents/$MANIFEST_FILE?ref=$BRANCH\" --jq '.sha' 2>/dev/null || true)") + // Build the API arguments. -f message handles a multi-line message safely. + w(" API_ARGS=(\"repos/${{ github.repository }}/contents/$MANIFEST_FILE\" -X PUT") + w(" -f \"message=%s\"", shellSingleLineMessage(params.commitMessage)) + w(" -f \"content=$CONTENT_B64\"") + w(" -f \"branch=$BRANCH\")") + w(" if [[ -n \"$CURRENT_SHA\" ]]; then") + w(" API_ARGS+=(-f \"sha=$CURRENT_SHA\")") + w(" fi") + w(" if gh api \"${API_ARGS[@]}\" >/dev/null; then") + w(" echo \"%s via API on attempt $attempt\"", params.successLabel) + w(" exit 0") + w(" fi") + w(" echo \"State write attempt $attempt failed (likely concurrent run); retrying...\" >&2") + w(" sleep $((RANDOM % 5 + 2))") + w("done") + w("echo \"::error::Failed to write state via API after 5 attempts\" >&2") + w("exit 1") +} + +// writeShellCommit emits a `git commit -m` line for a possibly multi-line +// message at the given indent. Multi-line messages preserve the existing +// behavior of embedding a trailer line under the subject. +func writeShellCommit(sb *strings.Builder, indent, message string) { + lines := strings.Split(message, "\n") + if len(lines) == 1 { + fmt.Fprintf(sb, "%sgit commit -m \"%s\"\n", indent, lines[0]) + return + } + fmt.Fprintf(sb, "%sgit commit -m \"%s\n", indent, lines[0]) + for i := 1; i < len(lines); i++ { + if i == len(lines)-1 { + fmt.Fprintf(sb, "%s%s\"\n", indent, lines[i]) + } else { + fmt.Fprintf(sb, "%s%s\n", indent, lines[i]) + } + } +} + +// shellSingleLineMessage collapses a possibly multi-line commit message into a +// single line for the REST API -f message argument, joining lines with a space. +// The Contents API takes the full message as one field value; a single line +// keeps the emitted shell readable while preserving the subject and any trailer. +func shellSingleLineMessage(message string) string { + parts := strings.Split(message, "\n") + var out []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return strings.Join(out, " ") +} diff --git a/internal/generate/state_write_test.go b/internal/generate/state_write_test.go new file mode 100644 index 00000000..7d9cc010 --- /dev/null +++ b/internal/generate/state_write_test.go @@ -0,0 +1,160 @@ +package generate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stateWriteGitHubBranch is the marker line that opens the API path of the +// generated state-write logic. +const stateWriteServerCheck = `if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then` + +// assertDualStateWrite asserts the rendered workflow contains both the GitHub +// REST API state-write path and the gitea/act git-push path. +func assertDualStateWrite(t *testing.T, content string) { + t.Helper() + + // The environment detection that splits the two paths, reused from the + // "Only dispatch on real GitHub" dispatch pattern. + assert.Contains(t, content, stateWriteServerCheck, + "state write must branch on GITHUB_SERVER_URL to detect act/gitea vs real GitHub") + + // gitea/act path: the existing git push to the trunk branch must be preserved + // so e2e keeps passing (gitea enforces neither protection nor signatures). + assert.Contains(t, content, `git push origin "HEAD:$BRANCH"`, + "gitea/act path must still push state with plain git") + + // Real GitHub path: write through the Contents REST API so the commit is + // signed and can bypass branch protection. + assert.Contains(t, content, `gh api "${API_ARGS[@]}"`, + "real GitHub path must write state through the Contents REST API") + assert.Contains(t, content, "/contents/$MANIFEST_FILE", + "API path must target the manifest via the Contents API") + assert.Contains(t, content, "-X PUT", + "Contents API write must be a PUT") +} + +// TestOrchestrateFinalizeDualStateWrite verifies the orchestrate Update Manifest +// step emits both the API and git-push paths and authenticates the API with the +// state token. +func TestOrchestrateFinalizeDualStateWrite(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte("on:\n workflow_call:\n"), 0644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + } + + gen := NewGenerator(cfg, tmpDir) + content, err := gen.Generate() + require.NoError(t, err) + + assertDualStateWrite(t, content) + + // The Update Manifest step must expose GH_TOKEN for the API call, defaulting + // to the standard token. + assert.Contains(t, content, "GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}", + "Update Manifest must default GH_TOKEN to GITHUB_TOKEN") +} + +// TestOrchestrateFinalizeStateTokenOverride verifies the configurable state +// token flows into the orchestrate API auth. +func TestOrchestrateFinalizeStateTokenOverride(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte("on:\n workflow_call:\n"), 0644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + StateToken: "${{ secrets.CASCADE_BOT_TOKEN }}", + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + } + + gen := NewGenerator(cfg, tmpDir) + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "GH_TOKEN: ${{ secrets.CASCADE_BOT_TOKEN }}", + "configured state_token must authenticate the API state write") +} + +// TestReleaseFinalizeDualStateWrite verifies the release finalize job emits both +// state-write paths for latest_release. +func TestReleaseFinalizeDualStateWrite(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"prod"}, + } + + gen := NewReleaseGenerator(cfg, "") + content, err := gen.Generate() + require.NoError(t, err) + + assertDualStateWrite(t, content) + assert.Contains(t, content, "GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}", + "Update Latest Release State must default GH_TOKEN to GITHUB_TOKEN") +} + +// TestPromoteFinalizeStateTokenAuth verifies the promote finalize step authes +// the CLI's API state write with the state token. +func TestPromoteFinalizeStateTokenAuth(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + StateToken: "${{ secrets.CASCADE_BOT_TOKEN }}", + } + + gen := NewPromoteGenerator(cfg, tmpDir) + content, err := gen.Generate() + require.NoError(t, err) + + // The promote finalize runs `cascade promote finalize --commit-push`, whose + // CLI performs the API write on real GitHub; GH_TOKEN must carry the state + // token so the API call can bypass branch protection. + require.Contains(t, content, "cascade promote finalize") + assert.Contains(t, content, "GH_TOKEN: ${{ secrets.CASCADE_BOT_TOKEN }}", + "Finalize Promotion must auth the API state write with state_token") +} + +// TestStateWriteNoEmDash guards the hard project rule that generated output +// contains no em dashes. +func TestStateWriteNoEmDash(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte("on:\n workflow_call:\n"), 0644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + } + + orch, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + prom, err := NewPromoteGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + rel, err := NewReleaseGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + for name, content := range map[string]string{"orchestrate": orch, "promote": prom, "release": rel} { + assert.False(t, strings.ContainsRune(content, '—'), "%s output must contain no em dashes", name) + } +} diff --git a/internal/promote/finalize.go b/internal/promote/finalize.go index e62e7370..6c6eb120 100644 --- a/internal/promote/finalize.go +++ b/internal/promote/finalize.go @@ -1,9 +1,11 @@ package promote import ( + "encoding/base64" "fmt" "os" "os/exec" + "strings" "time" "github.com/stablekernel/cascade/internal/config" @@ -241,14 +243,15 @@ func (f *Finalizer) WriteConfig() error { return nil } -// CommitAndPush commits the manifest changes and pushes to the remote repository. -// This should be called after Run() to persist state changes to git. +// CommitAndPush persists the manifest changes back to the trunk branch. // -// It performs the following steps: -// 1. Checks if there are any changes to commit -// 2. Configures git user (if not external mode) -// 3. Commits the manifest with [skip ci] to avoid triggering workflows -// 4. Pushes the commit to the remote +// On real GitHub the write goes through the Contents REST API (via the gh CLI): +// API-created commits are signed by GitHub (shown as Verified) and, when made +// with a bypass-capable token, update the trunk even when a required status +// check protects the branch. In the act/gitea e2e environment there is no +// GitHub API, so the change is committed and pushed with plain git. The +// environment is detected exactly as the generated dispatch steps do, by +// GITHUB_SERVER_URL != https://github.com. // // Note: We skip git pull because the workflow just checked out the repo, // so it should already be at the latest state. The finalize job runs after @@ -264,8 +267,69 @@ func (f *Finalizer) CommitAndPush() error { return nil // No changes } + message := fmt.Sprintf("chore: update state after promotion to %s [skip ci]", f.targetEnv) + + if isRealGitHub() { + return f.writeStateViaAPI(message) + } + return f.commitAndPushGit(message) +} + +// isRealGitHub reports whether the workflow is running on github.com rather than +// an act/gitea e2e environment. This mirrors the "Only dispatch on real GitHub" +// detection used by the generated workflows. +func isRealGitHub() bool { + server := os.Getenv("GITHUB_SERVER_URL") + return server == "" || server == "https://github.com" +} + +// writeStateViaAPI writes the manifest file to the trunk branch through the +// GitHub Contents REST API using the gh CLI. This produces a signed (Verified) +// commit and, with a bypass-capable token, can update a protected branch. +func (f *Finalizer) writeStateViaAPI(message string) error { + repo := os.Getenv("GITHUB_REPOSITORY") + if repo == "" { + return fmt.Errorf("GITHUB_REPOSITORY is not set; cannot write state via API") + } + branch := trunkBranchFromEnv() + + data, err := os.ReadFile(f.configPath) + if err != nil { + return fmt.Errorf("read manifest failed: %w", err) + } + contentB64 := base64.StdEncoding.EncodeToString(data) + + apiPath := fmt.Sprintf("repos/%s/contents/%s", repo, f.configPath) + + // Fetch the current blob SHA so the API performs an update rather than a + // create. An empty result means the file does not yet exist on the branch. + shaCmd := exec.Command("gh", "api", fmt.Sprintf("%s?ref=%s", apiPath, branch), "--jq", ".sha") + shaOut, _ := shaCmd.Output() + currentSHA := strings.TrimSpace(string(shaOut)) + + args := []string{ + "api", apiPath, "-X", "PUT", + "-f", "message=" + message, + "-f", "content=" + contentB64, + "-f", "branch=" + branch, + } + if currentSHA != "" { + args = append(args, "-f", "sha="+currentSHA) + } + + cmd := exec.Command("gh", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("state write via API failed: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +// commitAndPushGit commits the manifest and pushes with plain git. Used in the +// act/gitea e2e environment, which enforces neither branch protection nor +// commit signatures. +func (f *Finalizer) commitAndPushGit(message string) error { // Configure git (use default bot identity) - cmd = exec.Command("git", "config", "user.name", "github-actions[bot]") + cmd := exec.Command("git", "config", "user.name", "github-actions[bot]") if err := cmd.Run(); err != nil { return fmt.Errorf("git config user.name failed: %w", err) } @@ -281,14 +345,11 @@ func (f *Finalizer) CommitAndPush() error { return fmt.Errorf("git add failed: %w", err) } - // Commit with [skip ci] to avoid triggering workflows - message := fmt.Sprintf("chore: update state after promotion to %s [skip ci]", f.targetEnv) cmd = exec.Command("git", "commit", "-m", message) if err := cmd.Run(); err != nil { return fmt.Errorf("git commit failed: %w", err) } - // Push to remote cmd = exec.Command("git", "push") if err := cmd.Run(); err != nil { return fmt.Errorf("git push failed: %w", err) @@ -297,6 +358,20 @@ func (f *Finalizer) CommitAndPush() error { return nil } +// trunkBranchFromEnv resolves the branch to write state to. Push events check +// out in detached HEAD, so the branch is taken from GITHUB_REF when present, +// falling back to "main". +func trunkBranchFromEnv() string { + ref := os.Getenv("GITHUB_REF") + if strings.HasPrefix(ref, "refs/heads/") { + return strings.TrimPrefix(ref, "refs/heads/") + } + if ref != "" { + return ref + } + return "main" +} + // isExternalDeploy checks if a deploy is an external deploy (from satellite repo) func (f *Finalizer) isExternalDeploy(name string) bool { for _, ext := range f.cicdFile.Config.External { diff --git a/internal/promote/finalize_test.go b/internal/promote/finalize_test.go index 7c095391..71ab61f6 100644 --- a/internal/promote/finalize_test.go +++ b/internal/promote/finalize_test.go @@ -403,6 +403,31 @@ func TestFinalize_SetActor(t *testing.T) { require.Equal(t, "custom-user", cicdFile.State["test"].CommittedBy) } +// TestIsRealGitHub verifies the act/gitea vs real GitHub detection that decides +// whether CommitAndPush writes via the REST API or plain git push. +func TestIsRealGitHub(t *testing.T) { + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + require.True(t, isRealGitHub(), "github.com must be detected as real GitHub") + + t.Setenv("GITHUB_SERVER_URL", "") + require.True(t, isRealGitHub(), "unset server URL defaults to real GitHub") + + t.Setenv("GITHUB_SERVER_URL", "http://gitea:3000") + require.False(t, isRealGitHub(), "gitea server URL must take the git-push path") +} + +// TestTrunkBranchFromEnv verifies branch resolution for the API state write. +func TestTrunkBranchFromEnv(t *testing.T) { + t.Setenv("GITHUB_REF", "refs/heads/main") + require.Equal(t, "main", trunkBranchFromEnv()) + + t.Setenv("GITHUB_REF", "develop") + require.Equal(t, "develop", trunkBranchFromEnv()) + + t.Setenv("GITHUB_REF", "") + require.Equal(t, "main", trunkBranchFromEnv()) +} + // TestFinalize_SkippedDeploys tests that skipped deploys don't update deploy state. func TestFinalize_SkippedDeploys(t *testing.T) { tmpDir := t.TempDir()