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
15 changes: 15 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 == "" {
Expand Down
10 changes: 10 additions & 0 deletions internal/config/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
49 changes: 23 additions & 26 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.uses) own
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions internal/generate/promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
47 changes: 22 additions & 25 deletions internal/generate/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
150 changes: 150 additions & 0 deletions internal/generate/state_write.go
Original file line number Diff line number Diff line change
@@ -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, " ")
}
Loading
Loading