From b5486165c92e0c3093cd3aeeb547addf3139506e Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 22 Jun 2026 20:18:24 -0400 Subject: [PATCH 1/2] feat: default release_token to state_token to arm the rc-to-release chain Signed-off-by: Joshua Temple --- docs/src/content/docs/configuration.md | 6 +- ...release-token-defaults-to-state-token.yaml | 48 +++++++++ internal/config/types.go | 30 +++++- internal/config/types_test.go | 97 ++++++++++++++++--- internal/generate/app_token_source_test.go | 32 ++++++ 5 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 e2e/scenarios/orchestrate/23-release-token-defaults-to-state-token.yaml diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 8c0a913e..423b417e 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -85,7 +85,7 @@ ci: | `cli_version` | string | No | latest | CLI version: `latest`, `beta`, or specific version (e.g., `v2.0.4`) | | `triggers` | list | No | - | Global path patterns that activate orchestration | | `tag_prefix` | string | No | `v` | Version tag prefix | -| `release_token` | string | No | `${{ secrets.GITHUB_TOKEN }}` | Token expression for release API calls | +| `release_token` | string | No | `state_token` if set, else `${{ secrets.GITHUB_TOKEN }}` | Token expression for release API calls and the rc tag; inherits `state_token` when unset so the rc-to-release chain has a trigger-capable token | | `state_token` | string | No | `${{ secrets.GITHUB_TOKEN }}` | Token expression for writing manifest state to the trunk branch | | `release_token_app` | object | No | - | GitHub App identity that mints a release token at run time; see [Token authentication](#token-authentication) | | `state_token_app` | object | No | - | GitHub App identity that mints a state-write token at run time; see [Token authentication](#token-authentication) | @@ -132,6 +132,10 @@ ci: state_token: STATE_PAT ``` +:::caution[`release_token` defaults to `state_token`, and must be trigger-capable] +The release token creates the rc tag, and that tag is what triggers the Release run, fleet validation, and promotion. GitHub deliberately suppresses workflow triggers for ref creations made with the default `GITHUB_TOKEN`, so an rc tag created with `GITHUB_TOKEN` fires nothing and the rc-to-release chain dies silently. To avoid that, an unset `release_token` inherits your `state_token` when one is set, reusing the trigger-capable token you already configured for protected-trunk writes. Whatever resolves as the release token must be trigger-capable (a PAT or a GitHub App token) for the automatic chain to run. If your state token is supplied solely through `state_token_app` (no static `state_token`), set a static `release_token` explicitly, since a minted App token is a run-time step output that this default cannot reach. +::: + #### GitHub App A GitHub App avoids storing a long-lived PAT. cascade mints a fresh installation token per run, scoped to the App's least-privilege permissions and short-lived by construction. Only the App private key is ever stored as a secret; no PAT lives in your secret store. diff --git a/e2e/scenarios/orchestrate/23-release-token-defaults-to-state-token.yaml b/e2e/scenarios/orchestrate/23-release-token-defaults-to-state-token.yaml new file mode 100644 index 00000000..6aaab693 --- /dev/null +++ b/e2e/scenarios/orchestrate/23-release-token-defaults-to-state-token.yaml @@ -0,0 +1,48 @@ +name: "Release Token Defaults To State Token" +description: | + Reproduces the rc-to-release dead-chain footgun: an adopter sets a trigger- + capable state_token (used for protected-trunk state writes) but leaves + release_token unset. The release token creates the rc tag, and a tag created + with the default GITHUB_TOKEN fires no downstream workflows, so the + Release/promote chain would die silently. + + With this fix, an unset release_token inherits the state_token expression, so + the rc-creating release steps reference the trigger-capable token. This + scenario asserts the generated orchestrate workflow references the state token + for release operations and never falls back to GITHUB_TOKEN there. + + Generator-output verification scenario; assertion runs on the staged repo after + StageRepoFromConfig generates workflows but before any orchestrate runs. + +config: + trunk_branch: main + environments: ["dev", "test", "staging", "prod"] + state_token: CASCADE_BOT_TOKEN + builds: + - name: app + workflow: build-app.yaml + triggers: ["src/**"] + deploys: + - name: app + workflow: deploy-app.yaml + triggers: [] + +steps: + - name: "Initial commit; assert the release token inherits the state token" + action: commit + commit: + message: "feat: add app build and deploy with a state_token but no release_token" + files: + src/main.go: | + package main + func main() {} + expect: + workflow_files: + - path: ".github/workflows/orchestrate.yaml" + contains: + # Release operations use the trigger-capable state token, wrapped. + - "token: ${{ secrets.CASCADE_BOT_TOKEN }}" + not_contains: + # The release token must not silently fall back to GITHUB_TOKEN, + # whose ref-creations do not trigger downstream workflows. + - "token: ${{ secrets.GITHUB_TOKEN }}" diff --git a/internal/config/types.go b/internal/config/types.go index abfb9bf5..095e0c1d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -325,10 +325,36 @@ func normalizeTokenExpression(value string) string { } // GetReleaseToken returns the configured release token as a resolvable GitHub -// Actions expression, or "${{ secrets.GITHUB_TOKEN }}" if not specified. A bare -// secret name (e.g. "MY_TOKEN") is normalized to "${{ secrets.MY_TOKEN }}". +// Actions expression. A bare secret name (e.g. "MY_TOKEN") is normalized to +// "${{ secrets.MY_TOKEN }}". +// +// When release_token is unset, the result depends on state_token. The release +// token creates the rc tag, and creating a tag that must trigger downstream +// workflows (Release, the fleet, promotion) requires a trigger-capable token: +// GitHub deliberately suppresses workflow triggers for ref creations made with +// the default GITHUB_TOKEN. So when an adopter has supplied a trigger-capable +// state_token (typically a PAT or App-backed token for protected-trunk writes) +// but left release_token unset, defaulting the release token to GITHUB_TOKEN +// would silently break the rc-to-release chain: the rc tag fires nothing. +// +// To avoid that silent dead-chain, an unset release_token falls back to the +// state token expression whenever state_token is set, reusing the trigger- +// capable token the adopter already configured. When both are unset, the +// historical "${{ secrets.GITHUB_TOKEN }}" default is preserved for +// back-compatibility (a single-token, same-repo setup does not need the +// chain). When release_token is set, it always wins. +// +// Note on App-backed state tokens: a minted App token lives in a generator +// step output, not in config, so this config-layer resolver can only fall back +// to the static state_token string. An adopter whose state token is supplied +// solely via state_token_app (no static state_token) keeps the GITHUB_TOKEN +// default here; to arm the rc-to-release chain in that shape, set a static, +// trigger-capable release_token explicitly. func (c *TrunkConfig) GetReleaseToken() string { if c.ReleaseToken == "" { + if c.StateToken != "" { + return normalizeTokenExpression(c.StateToken) + } return "${{ secrets.GITHUB_TOKEN }}" } return normalizeTokenExpression(c.ReleaseToken) diff --git a/internal/config/types_test.go b/internal/config/types_test.go index b490bd9f..440ea48b 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -420,23 +420,88 @@ func TestGetTagPrefix(t *testing.T) { } func TestGetReleaseToken(t *testing.T) { - // Default when not set - cfg := &TrunkConfig{} - assert.Equal(t, "${{ secrets.GITHUB_TOKEN }}", cfg.GetReleaseToken()) + tests := []struct { + name string + releaseToken string + stateToken string + stateApp *AppTokenSource + want string + }{ + { + // Back-compat: neither token set keeps today's GITHUB_TOKEN default. + name: "both unset defaults to GITHUB_TOKEN", + want: "${{ secrets.GITHUB_TOKEN }}", + }, + { + // Explicit release_token always wins, unchanged. + name: "release_token full expression passes through", + releaseToken: "${{ secrets.CUSTOM_RELEASE_TOKEN }}", + want: "${{ secrets.CUSTOM_RELEASE_TOKEN }}", + }, + { + // Bare secret name is normalized; the field advertises a "GitHub + // secret name", so a bare value must not be emitted verbatim. + name: "release_token bare name normalizes to secret", + releaseToken: "CASCADE_STATE_TOKEN", + want: "${{ secrets.CASCADE_STATE_TOKEN }}", + }, + { + // Unwrapped context form is wrapped. + name: "release_token context form wraps", + releaseToken: "secrets.CASCADE_STATE_TOKEN", + want: "${{ secrets.CASCADE_STATE_TOKEN }}", + }, + { + // The footgun fix: release_token unset but state_token set falls + // back to the state token expression so the rc tag is created with + // a trigger-capable token and the rc-to-release chain fires. + name: "state_token set release_token unset falls back to state token", + stateToken: "${{ secrets.CASCADE_BOT_TOKEN }}", + want: "${{ secrets.CASCADE_BOT_TOKEN }}", + }, + { + // The fallback normalizes a bare state_token name too. + name: "bare state_token name normalizes on fallback", + stateToken: "CASCADE_BOT_TOKEN", + want: "${{ secrets.CASCADE_BOT_TOKEN }}", + }, + { + // Explicit release_token still wins over a set state_token. + name: "release_token wins over state_token", + releaseToken: "${{ secrets.CUSTOM_RELEASE_TOKEN }}", + stateToken: "${{ secrets.CASCADE_BOT_TOKEN }}", + want: "${{ secrets.CUSTOM_RELEASE_TOKEN }}", + }, + { + // App-backed state with a static state_token falls back to the + // static state token (the App mint output is a generator-level step + // output, not a config value). + name: "app-backed state with static state_token falls back to static", + stateToken: "${{ secrets.CASCADE_BOT_TOKEN }}", + stateApp: &AppTokenSource{AppID: "CASCADE_APP_ID", PrivateKey: "CASCADE_APP_PRIVATE_KEY"}, + want: "${{ secrets.CASCADE_BOT_TOKEN }}", + }, + { + // App-only state (state_token_app set, no static state_token, no + // release_token) cannot be resolved to a trigger-capable token at + // the config layer, so it keeps the GITHUB_TOKEN default. Adopters + // in this shape set a static release_token to arm the chain. + name: "app-only state without static token keeps GITHUB_TOKEN default", + stateApp: &AppTokenSource{AppID: "CASCADE_APP_ID", PrivateKey: "CASCADE_APP_PRIVATE_KEY"}, + want: "${{ secrets.GITHUB_TOKEN }}", + }, + } - // Configured value (full expression) - cfg.ReleaseToken = "${{ secrets.CUSTOM_RELEASE_TOKEN }}" - assert.Equal(t, "${{ secrets.CUSTOM_RELEASE_TOKEN }}", cfg.GetReleaseToken()) - - // Bare secret name is normalized to a resolvable secrets expression. - // The field doc advertises a "GitHub secret name", so a bare name must - // not be emitted verbatim (that produces a literal token and a 401). - cfg.ReleaseToken = "CASCADE_STATE_TOKEN" - assert.Equal(t, "${{ secrets.CASCADE_STATE_TOKEN }}", cfg.GetReleaseToken()) - - // Unwrapped context form is also wrapped. - cfg.ReleaseToken = "secrets.CASCADE_STATE_TOKEN" - assert.Equal(t, "${{ secrets.CASCADE_STATE_TOKEN }}", cfg.GetReleaseToken()) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &TrunkConfig{ + ReleaseToken: tt.releaseToken, + StateToken: tt.stateToken, + StateTokenApp: tt.stateApp, + } + assert.Equal(t, tt.want, cfg.GetReleaseToken()) + }) + } } func TestGetStateToken(t *testing.T) { diff --git a/internal/generate/app_token_source_test.go b/internal/generate/app_token_source_test.go index 80921734..6cff39ee 100644 --- a/internal/generate/app_token_source_test.go +++ b/internal/generate/app_token_source_test.go @@ -105,6 +105,38 @@ func TestResolveTokenRef_OffStateIdentity(t *testing.T) { assert.Equal(t, cfg.GetStateToken(), resolveStateTokenRef(cfg)) } +// TestReleaseGenerator_ReleaseTokenDefaultsToStateToken asserts that when an +// adopter configures a trigger-capable state_token but leaves release_token +// unset, the rc-creating release steps reference the state token, not the bare +// GITHUB_TOKEN. This is the rc-to-release dead-chain fix: a tag created with +// GITHUB_TOKEN fires no downstream workflows, so the release token must inherit +// the trigger-capable state token the adopter already supplied. +func TestReleaseGenerator_ReleaseTokenDefaultsToStateToken(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"prod"}, + StateToken: "${{ secrets.CASCADE_BOT_TOKEN }}", + } + content, err := NewReleaseGenerator(cfg, "").Generate() + require.NoError(t, err) + + // Release operations reference the state token, not GITHUB_TOKEN. + assert.Contains(t, content, "token: ${{ secrets.CASCADE_BOT_TOKEN }}") + assert.NotContains(t, content, "token: ${{ secrets.GITHUB_TOKEN }}") +} + +// TestReleaseGenerator_BothTokensUnsetKeepsGithubToken asserts the OFF/back- +// compat state: with neither release_token nor state_token set, release steps +// keep emitting the historical GITHUB_TOKEN default byte-for-byte. +func TestReleaseGenerator_BothTokensUnsetKeepsGithubToken(t *testing.T) { + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"prod"}} + content, err := NewReleaseGenerator(cfg, "").Generate() + require.NoError(t, err) + + assert.Contains(t, content, "token: ${{ secrets.GITHUB_TOKEN }}") + assert.NotContains(t, content, "CASCADE_BOT_TOKEN") +} + // TestMintStepIndentation asserts the minting step is emitted at the standard // 6-space step indent so it nests correctly under the job's steps: block. func TestMintStepIndentation(t *testing.T) { From feab7f5260d9061b36c3049d938dc6f2937486ad Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 22 Jun 2026 21:00:35 -0400 Subject: [PATCH 2/2] test(e2e): carry state_token through the harness Config so the release-token-default scenario sets it Signed-off-by: Joshua Temple --- e2e/harness/scenario.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index 8d81f459..65623f91 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -35,6 +35,11 @@ type Config struct { // manifest. It accepts a full ${{ secrets.* }} expression or a bare secret // name; the generator normalizes a bare name to a resolvable expression. ReleaseToken string `yaml:"release_token,omitempty"` + // StateToken carries the state_token field through to the generated manifest, + // the same way ReleaseToken does. Without this field a scenario's state_token + // is silently dropped on marshal, so the generated workflows fall back to the + // default token. + StateToken string `yaml:"state_token,omitempty"` // ReleaseTokenApp and StateTokenApp carry the optional GitHub App identities // (app_id, private_key secret references) through to the generated manifest // untouched. A generic map keeps the harness decoupled from the generator's