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
23 changes: 23 additions & 0 deletions docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@
}
}
},
"appTokenSource": {
"type": "object",
"additionalProperties": false,
"description": "A GitHub App identity that backs a token seam. The generator mints a short-lived installation token at run time via actions/create-github-app-token. app_id and private_key are secret references (a secrets/vars expression or bare secret name), never raw key material.",
"properties": {
"app_id": {
"type": "string",
"description": "Secret reference for the GitHub App ID (a secrets/vars expression or bare secret name, for example CASCADE_APP_ID)."
},
"private_key": {
"type": "string",
"description": "Secret reference for the GitHub App private key (a secrets/vars expression or bare secret name, for example CASCADE_APP_PRIVATE_KEY). Never the raw key material."
}
}
},
"trunkConfig": {
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -76,6 +91,14 @@
"type": "string",
"description": "GitHub Actions secret expression for writing manifest state back to the trunk branch (default: ${{ secrets.GITHUB_TOKEN }})."
},
"release_token_app": {
"$ref": "#/definitions/appTokenSource",
"description": "Optional GitHub App identity that mints a short-lived installation token for release operations at run time instead of using a static secret. app_id and private_key are secret references, never raw key material."
},
"state_token_app": {
"$ref": "#/definitions/appTokenSource",
"description": "Optional GitHub App identity that mints a short-lived installation token for writing manifest state at run time instead of using a static secret. app_id and private_key are secret references, never raw key material."
},
"manifest_file": {
"type": "string",
"description": "Path to the manifest file (default: \".github/manifest.yaml\")."
Expand Down
55 changes: 55 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ ci:
| `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 |
| `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) |
| `manifest_file` | string | No | `.github/manifest.yaml` | Path to manifest file |
| `manifest_key` | string | No | `ci` | Top-level key inside the manifest file |
| `action_folder` | string | No | `manage-release` | Folder name for the manage-release action |
Expand All @@ -108,6 +111,58 @@ Controls which CLI version the generated workflows install via setup-cli:

Pin to a specific version for reproducibility. Use `beta` for early access.

### Token authentication

Two seams call GitHub on cascade's behalf: `release_token` for release API calls and `state_token` for writing manifest state back to the trunk branch. Both default to `${{ secrets.GITHUB_TOKEN }}`, which is enough for a single-repo project whose trunk is unprotected. When the default token cannot do the job, supply your own token through one of two paths: a static secret (PAT) or a GitHub App.

#### Static secret (PAT)

Set `release_token` or `state_token` to a custom secret expression when the default `GITHUB_TOKEN` falls short:

- **Pulling a private-source CLI.** Installing the cascade CLI from a private repository or registry needs a token with read access to that source.
- **Cross-repo dispatch.** Coordinating builds or deploys in other repositories requires a token scoped beyond the current repository.
- **Writing to a protected trunk.** `GITHUB_TOKEN` cannot bypass branch protection, so it cannot push manifest state to a protected trunk branch. A PAT (or a GitHub App token) can bypass protection and produces a verified, signed commit.

Reference your secrets by bare name. cascade wraps a bare name in a `${{ secrets.* }}` expression for you:

```yaml
ci:
config:
release_token: RELEASE_PAT
state_token: STATE_PAT
```

#### 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.

One-time operator setup:

1. Create a GitHub App in your organization (for example, under `my-org`).
2. Generate a private key for the App and download the key file.
3. Install the App on the repository (or repositories) cascade runs in.
4. Add the App to the repository ruleset bypass list so it can write the protected trunk branch.
5. Store the App ID and the private key as GitHub secrets, for example `CASCADE_APP_ID` and `CASCADE_APP_PRIVATE_KEY`. Store only the private key as a secret, never the raw key material in the manifest.

Then point the manifest at those secrets with `release_token_app` and `state_token_app`. Each takes an `app_id` and a `private_key`, both secret references (a bare secret name or a `secrets`/`vars` expression):

```yaml
ci:
config:
release_token_app:
app_id: CASCADE_APP_ID
private_key: CASCADE_APP_PRIVATE_KEY
state_token_app:
app_id: CASCADE_APP_ID
private_key: CASCADE_APP_PRIVATE_KEY
```

When an App source is set, the generated workflow mints a short-lived installation token at run time via the `actions/create-github-app-token` action, guarded to real GitHub with `if: ${{ github.server_url == 'https://github.com' }}`. The token consumers prefer the minted token.

:::note[Local act/gitea is unaffected]
On act or gitea the minting step is skipped (the `github.server_url` guard does not match), and the consumers fall back to the static `release_token` / `state_token`. Set both the App source and a static token if you run the same manifest locally and against real GitHub.
:::

### git Section

Optional git identity and signing configuration for state commits:
Expand Down
7 changes: 7 additions & 0 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ 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"`
// 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
// AppTokenSource shape while preserving every key across the marshal
// round-trip.
ReleaseTokenApp map[string]any `yaml:"release_token_app,omitempty"`
StateTokenApp map[string]any `yaml:"state_token_app,omitempty"`
Builds []BuildConfig `yaml:"builds"`
Deploys []DeployConfig `yaml:"deploys"`
Publish *PublishConfig `yaml:"publish,omitempty"`
Expand Down
46 changes: 46 additions & 0 deletions e2e/scenarios/30-app-token-source.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: "App Token Source"
description: |
Exercises the optional GitHub App token sources (config.release_token_app and
config.state_token_app). When set, the generator emits an
actions/create-github-app-token minting step guarded to real GitHub and rewires
the release and state token refs to prefer the minted token, falling back to the
static token on act/gitea. app_id and private_key are secret references, never
raw key material. On act/gitea the minting step is skipped by its
github.server_url guard, so the workflow stays runnable on the static token.
This scenario declares both app sources, generates the workflows, then
regenerates and proves the output is byte-identical with no drift.

config:
trunk_branch: main
environments: [dev, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["src/**"]
release_token_app:
app_id: CASCADE_APP_ID
private_key: CASCADE_APP_PRIVATE_KEY
state_token_app:
app_id: ${{ vars.CASCADE_APP_ID }}
private_key: ${{ secrets.CASCADE_APP_PRIVATE_KEY }}

steps:
- name: "Seed a minimal source tree"
action: commit
commit:
message: "seed source"
files:
src/main.go: |
package main

func main() {}

- name: "Regenerate and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0
60 changes: 60 additions & 0 deletions internal/config/app_token_source_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package config

import (
"os"
"path/filepath"
"testing"
)

// appTokenManifest exercises the release_token_app / state_token_app surface
// through the on-disk load path, asserting it parses and validates cleanly while
// leaving the schema generation at the current version.
const appTokenManifest = `ci:
config:
schema_version: 1
trunk_branch: main
environments: [dev, prod]
release_token_app:
app_id: CASCADE_APP_ID
private_key: CASCADE_APP_PRIVATE_KEY
state_token_app:
app_id: ${{ vars.CASCADE_APP_ID }}
private_key: ${{ secrets.CASCADE_APP_PRIVATE_KEY }}
`

func TestAppTokenManifestE2E(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "manifest.yaml")
if err := os.WriteFile(path, []byte(appTokenManifest), 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}

cfg, err := Parse(path)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}

if errs := Validate(cfg); len(errs) != 0 {
t.Fatalf("expected a clean app-token manifest, got errors: %v", errs)
}

if !cfg.HasReleaseTokenApp() || !cfg.HasStateTokenApp() {
t.Fatalf("app token sources did not round-trip: release=%v state=%v",
cfg.HasReleaseTokenApp(), cfg.HasStateTokenApp())
}
if got := cfg.GetReleaseTokenAppID(); got != "${{ secrets.CASCADE_APP_ID }}" {
t.Fatalf("release app_id ref = %q", got)
}
if got := cfg.GetStateTokenAppPrivateKey(); got != "${{ secrets.CASCADE_APP_PRIVATE_KEY }}" {
t.Fatalf("state private_key ref = %q", got)
}

// The app token sources are additive: they must not change the schema
// generation the manifest is read at.
if cfg.GetSchemaVersion() != CurrentSchemaVersion {
t.Fatalf("schema version = %d, want %d", cfg.GetSchemaVersion(), CurrentSchemaVersion)
}
if CurrentSchemaVersion != 1 {
t.Fatalf("CurrentSchemaVersion = %d, want 1", CurrentSchemaVersion)
}
}
148 changes: 148 additions & 0 deletions internal/config/app_token_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package config

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestHasReleaseTokenApp(t *testing.T) {
t.Run("false when unset", func(t *testing.T) {
assert.False(t, (&TrunkConfig{}).HasReleaseTokenApp())
})
t.Run("true when set", func(t *testing.T) {
cfg := &TrunkConfig{ReleaseTokenApp: &AppTokenSource{AppID: "CASCADE_APP_ID"}}
assert.True(t, cfg.HasReleaseTokenApp())
})
}

func TestHasStateTokenApp(t *testing.T) {
t.Run("false when unset", func(t *testing.T) {
assert.False(t, (&TrunkConfig{}).HasStateTokenApp())
})
t.Run("true when set", func(t *testing.T) {
cfg := &TrunkConfig{StateTokenApp: &AppTokenSource{AppID: "CASCADE_APP_ID"}}
assert.True(t, cfg.HasStateTokenApp())
})
}

func TestReleaseTokenAppAccessors(t *testing.T) {
t.Run("nil source returns empty", func(t *testing.T) {
cfg := &TrunkConfig{}
assert.Equal(t, "", cfg.GetReleaseTokenAppID())
assert.Equal(t, "", cfg.GetReleaseTokenAppPrivateKey())
})
t.Run("bare names wrap as secrets", func(t *testing.T) {
cfg := &TrunkConfig{ReleaseTokenApp: &AppTokenSource{
AppID: "CASCADE_APP_ID",
PrivateKey: "CASCADE_APP_PRIVATE_KEY",
}}
assert.Equal(t, "${{ secrets.CASCADE_APP_ID }}", cfg.GetReleaseTokenAppID())
assert.Equal(t, "${{ secrets.CASCADE_APP_PRIVATE_KEY }}", cfg.GetReleaseTokenAppPrivateKey())
})
t.Run("full expression passes through", func(t *testing.T) {
cfg := &TrunkConfig{ReleaseTokenApp: &AppTokenSource{
AppID: "${{ vars.CASCADE_APP_ID }}",
PrivateKey: "${{ secrets.CASCADE_APP_PRIVATE_KEY }}",
}}
assert.Equal(t, "${{ vars.CASCADE_APP_ID }}", cfg.GetReleaseTokenAppID())
assert.Equal(t, "${{ secrets.CASCADE_APP_PRIVATE_KEY }}", cfg.GetReleaseTokenAppPrivateKey())
})
}

func TestStateTokenAppAccessors(t *testing.T) {
t.Run("nil source returns empty", func(t *testing.T) {
cfg := &TrunkConfig{}
assert.Equal(t, "", cfg.GetStateTokenAppID())
assert.Equal(t, "", cfg.GetStateTokenAppPrivateKey())
})
t.Run("bare names wrap as secrets", func(t *testing.T) {
cfg := &TrunkConfig{StateTokenApp: &AppTokenSource{
AppID: "CASCADE_APP_ID",
PrivateKey: "CASCADE_APP_PRIVATE_KEY",
}}
assert.Equal(t, "${{ secrets.CASCADE_APP_ID }}", cfg.GetStateTokenAppID())
assert.Equal(t, "${{ secrets.CASCADE_APP_PRIVATE_KEY }}", cfg.GetStateTokenAppPrivateKey())
})
}

func TestValidateAppTokenSource(t *testing.T) {
tests := []struct {
name string
prefix string
src *AppTokenSource
wantErrs int
wantMatch string
}{
{
name: "nil is accepted",
prefix: "release_token_app",
src: nil,
wantErrs: 0,
},
{
name: "valid bare names accepted",
prefix: "release_token_app",
src: &AppTokenSource{AppID: "CASCADE_APP_ID", PrivateKey: "CASCADE_APP_PRIVATE_KEY"},
wantErrs: 0,
},
{
name: "valid secret expressions accepted",
prefix: "state_token_app",
src: &AppTokenSource{AppID: "${{ vars.CASCADE_APP_ID }}", PrivateKey: "${{ secrets.CASCADE_APP_PRIVATE_KEY }}"},
wantErrs: 0,
},
{
name: "missing private_key rejected",
prefix: "release_token_app",
src: &AppTokenSource{AppID: "CASCADE_APP_ID"},
wantErrs: 1,
wantMatch: "release_token_app.private_key is required",
},
{
name: "missing app_id rejected",
prefix: "state_token_app",
src: &AppTokenSource{PrivateKey: "CASCADE_APP_PRIVATE_KEY"},
wantErrs: 1,
wantMatch: "state_token_app.app_id is required",
},
{
name: "raw key material with newline rejected",
prefix: "release_token_app",
src: &AppTokenSource{AppID: "CASCADE_APP_ID", PrivateKey: "line1\nline2"},
wantErrs: 1,
wantMatch: "not raw key material",
},
{
name: "PRIVATE KEY marker rejected",
prefix: "release_token_app",
src: &AppTokenSource{AppID: "CASCADE_APP_ID", PrivateKey: "-----BEGIN RSA PRIVATE KEY-----"},
wantErrs: 1,
wantMatch: "not raw key material",
},
{
name: "malformed bare name rejected",
prefix: "state_token_app",
src: &AppTokenSource{AppID: "not a name!", PrivateKey: "CASCADE_APP_PRIVATE_KEY"},
wantErrs: 1,
wantMatch: "valid GitHub Actions secret name",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validateAppTokenSource(tt.prefix, tt.src)
assert.Len(t, errs, tt.wantErrs)
if tt.wantMatch != "" {
assert.Contains(t, joinErrs(errs), tt.wantMatch)
}
})
}
}

func joinErrs(errs []string) string {
out := ""
for _, e := range errs {
out += e + "\n"
}
return out
}
Loading
Loading