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
6 changes: 5 additions & 1 deletion docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }}"
30 changes: 28 additions & 2 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
97 changes: 81 additions & 16 deletions internal/config/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions internal/generate/app_token_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading