diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 21b76c8..23a16d6 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -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, @@ -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\")." diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 940cd3b..177ff20 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -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 | @@ -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: diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index b13b75e..0827465 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -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"` diff --git a/e2e/scenarios/30-app-token-source.yaml b/e2e/scenarios/30-app-token-source.yaml new file mode 100644 index 0000000..174e41e --- /dev/null +++ b/e2e/scenarios/30-app-token-source.yaml @@ -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 diff --git a/internal/config/app_token_source_e2e_test.go b/internal/config/app_token_source_e2e_test.go new file mode 100644 index 0000000..ebf6a45 --- /dev/null +++ b/internal/config/app_token_source_e2e_test.go @@ -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) + } +} diff --git a/internal/config/app_token_source_test.go b/internal/config/app_token_source_test.go new file mode 100644 index 0000000..be8be99 --- /dev/null +++ b/internal/config/app_token_source_test.go @@ -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 +} diff --git a/internal/config/types.go b/internal/config/types.go index 7ac7a79..12b5371 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -122,6 +122,14 @@ type TrunkConfig struct { 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") + // ReleaseTokenApp optionally backs the release-token seam with a GitHub App + // identity instead of a static secret. When set, the generator mints a + // short-lived installation token at run time and the release steps consume it. + ReleaseTokenApp *AppTokenSource `yaml:"release_token_app,omitempty" json:"release_token_app,omitempty"` + // StateTokenApp optionally backs the state-token seam with a GitHub App + // identity instead of a static secret. When set, the generator mints a + // short-lived installation token at run time and the state-write steps consume it. + StateTokenApp *AppTokenSource `yaml:"state_token_app,omitempty" json:"state_token_app,omitempty"` 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") @@ -335,6 +343,62 @@ func (c *TrunkConfig) GetStateToken() string { return normalizeTokenExpression(c.StateToken) } +// AppTokenSource backs a token seam with a GitHub App identity. At run time the +// generator emits an actions/create-github-app-token step that exchanges these +// for a short-lived installation token; the consuming steps reference that +// minted token instead of a static secret. AppID and PrivateKey are SECRET +// REFERENCES (a secrets/vars expression or bare secret name), never raw key +// material: the App private key is stored only as a GitHub secret. +type AppTokenSource struct { + AppID string `yaml:"app_id,omitempty" json:"app_id,omitempty"` + PrivateKey string `yaml:"private_key,omitempty" json:"private_key,omitempty"` +} + +// HasReleaseTokenApp reports whether a GitHub App identity backs the release-token seam. +func (c *TrunkConfig) HasReleaseTokenApp() bool { return c.ReleaseTokenApp != nil } + +// HasStateTokenApp reports whether a GitHub App identity backs the state-token seam. +func (c *TrunkConfig) HasStateTokenApp() bool { return c.StateTokenApp != nil } + +// GetReleaseTokenAppID returns the release App's app_id as a resolvable GitHub +// Actions expression (a bare name becomes "${{ secrets.NAME }}"), or "" when no +// release App identity is configured. +func (c *TrunkConfig) GetReleaseTokenAppID() string { + if c.ReleaseTokenApp == nil { + return "" + } + return normalizeTokenExpression(c.ReleaseTokenApp.AppID) +} + +// GetReleaseTokenAppPrivateKey returns the release App's private_key as a +// resolvable GitHub Actions expression, or "" when no release App identity is +// configured. The value is a secret reference, never raw key material. +func (c *TrunkConfig) GetReleaseTokenAppPrivateKey() string { + if c.ReleaseTokenApp == nil { + return "" + } + return normalizeTokenExpression(c.ReleaseTokenApp.PrivateKey) +} + +// GetStateTokenAppID returns the state App's app_id as a resolvable GitHub +// Actions expression, or "" when no state App identity is configured. +func (c *TrunkConfig) GetStateTokenAppID() string { + if c.StateTokenApp == nil { + return "" + } + return normalizeTokenExpression(c.StateTokenApp.AppID) +} + +// GetStateTokenAppPrivateKey returns the state App's private_key as a resolvable +// GitHub Actions expression, or "" when no state App identity is configured. The +// value is a secret reference, never raw key material. +func (c *TrunkConfig) GetStateTokenAppPrivateKey() string { + if c.StateTokenApp == nil { + return "" + } + return normalizeTokenExpression(c.StateTokenApp.PrivateKey) +} + // 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/validate_v1.go b/internal/config/validate_v1.go index 69fee01..69923d7 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -312,10 +312,65 @@ func validateConfigLevel(cfg *TrunkConfig) []string { errs = append(errs, validateTelemetry(cfg.Telemetry)...) errs = append(errs, validateEnvironmentConfig(cfg)...) + errs = append(errs, validateTokenSources(cfg)...) return errs } +// validateTokenSources checks the optional release_token_app / state_token_app +// GitHub App identities. Each is lenient when absent. When present, BOTH app_id +// and private_key must be set (a half-configured App is rejected), and each must +// be a secret reference: a bare GitHub Actions secret name or a +// "${{ secrets.* }}" / "${{ vars.* }}" expression. Raw key material is rejected +// outright (a value containing a newline or the substring "PRIVATE KEY"), since +// these fields are references, never inline credentials. +func validateTokenSources(cfg *TrunkConfig) []string { + var errs []string + errs = append(errs, validateAppTokenSource("release_token_app", cfg.ReleaseTokenApp)...) + errs = append(errs, validateAppTokenSource("state_token_app", cfg.StateTokenApp)...) + return errs +} + +// validateAppTokenSource checks a single App token source under the given prefix. +func validateAppTokenSource(prefix string, src *AppTokenSource) []string { + if src == nil { + return nil + } + var errs []string + if strings.TrimSpace(src.AppID) == "" { + errs = append(errs, fmt.Sprintf("%s.app_id is required when %s is set", prefix, prefix)) + } else { + errs = append(errs, validateSecretReference(prefix+".app_id", src.AppID)...) + } + if strings.TrimSpace(src.PrivateKey) == "" { + errs = append(errs, fmt.Sprintf("%s.private_key is required when %s is set", prefix, prefix)) + } else { + errs = append(errs, validateSecretReference(prefix+".private_key", src.PrivateKey)...) + } + return errs +} + +// validateSecretReference reports whether value is a secret reference rather than +// raw credential material. A reference is either a wrapped GitHub Actions +// expression ("${{ secrets.NAME }}" or "${{ vars.NAME }}") or a bare safe secret +// name. Any value carrying a newline or the substring "PRIVATE KEY" is rejected +// as raw key material. +func validateSecretReference(field, value string) []string { + if strings.ContainsAny(value, "\r\n") || strings.Contains(value, "PRIVATE KEY") { + return []string{fmt.Sprintf( + "%s must be a secret reference (a secret name or a ${{ secrets.* }} expression), not raw key material", field)} + } + trimmed := strings.TrimSpace(value) + if strings.HasPrefix(trimmed, "${{") && strings.HasSuffix(trimmed, "}}") { + return nil + } + if safeSecretName(trimmed) { + return nil + } + return []string{fmt.Sprintf( + "%s must be a valid GitHub Actions secret name or a ${{ secrets.* }} expression", field)} +} + // validateEnvironmentConfig checks the additive per-environment fields under // environment_config (required_reviewers, wait_timer, branch_policy and its // patterns, secrets, variables). Every check is lenient and applies only when a diff --git a/internal/generate/action_pins.go b/internal/generate/action_pins.go index 98fcd90..db4476d 100644 --- a/internal/generate/action_pins.go +++ b/internal/generate/action_pins.go @@ -18,6 +18,7 @@ const ( actionGithubScript = "actions/github-script" actionDownloadArtifact = "actions/download-artifact" actionUploadArtifact = "actions/upload-artifact" + actionCreateAppToken = "actions/create-github-app-token" ) // actionPin records the default mutable tag cascade emits in tag mode and the @@ -39,6 +40,7 @@ var defaultActionPins = map[string]actionPin{ actionGithubScript: {tag: "v7", sha: "f28e40c7f34bde8b3046d885e986cb6290c5673b", shaVersion: "v7.1.0"}, actionDownloadArtifact: {tag: "v4", sha: "d3f86a106a0bac45b974a628896c90dbdf5c8093", shaVersion: "v4.3.0"}, actionUploadArtifact: {tag: "v4", sha: "ea165f8d65b6e75b540449e92b4886f43607fa02", shaVersion: "v4.6.2"}, + actionCreateAppToken: {tag: "v3", sha: "bcd2ba49218906704ab6c1aa796996da409d3eb1", shaVersion: "v3.2.0"}, } // actionRef returns the fully-rendered uses: value for a third-party action diff --git a/internal/generate/app_token_source_test.go b/internal/generate/app_token_source_test.go new file mode 100644 index 0000000..8092173 --- /dev/null +++ b/internal/generate/app_token_source_test.go @@ -0,0 +1,167 @@ +package generate + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReleaseGenerator_NoAppTokenByDefault asserts the OFF-state: with no App +// token source configured, no minting step and no minted-token fallback ref are +// emitted, so generated output stays exactly as before. +func TestReleaseGenerator_NoAppTokenByDefault(t *testing.T) { + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"prod"}} + content, err := NewReleaseGenerator(cfg, "").Generate() + require.NoError(t, err) + + assert.NotContains(t, content, "create-github-app-token") + assert.NotContains(t, content, "cascade-release-app-token") + assert.NotContains(t, content, "cascade-state-app-token") + assert.NotContains(t, content, ".outputs.token ||") +} + +// TestReleaseGenerator_ReleaseAppTokenMints asserts that a configured release App +// identity injects the minting step into the release-consuming jobs, guards it to +// real GitHub, and rewires the consuming refs to the minted-token fallback while +// keeping the static GITHUB_TOKEN as the act/gitea fallback. +func TestReleaseGenerator_ReleaseAppTokenMints(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"prod"}, + ReleaseTokenApp: &config.AppTokenSource{ + AppID: "CASCADE_APP_ID", + PrivateKey: "CASCADE_APP_PRIVATE_KEY", + }, + } + content, err := NewReleaseGenerator(cfg, "").Generate() + require.NoError(t, err) + + assert.Contains(t, content, "- name: Mint release app token") + assert.Contains(t, content, "id: cascade-release-app-token") + assert.Contains(t, content, "if: ${{ github.server_url == 'https://github.com' }}") + assert.Contains(t, content, "uses: actions/create-github-app-token@") + assert.Contains(t, content, "app-id: ${{ secrets.CASCADE_APP_ID }}") + assert.Contains(t, content, "private-key: ${{ secrets.CASCADE_APP_PRIVATE_KEY }}") + + // Consuming refs prefer the minted token, with the static token as fallback. + assert.Contains(t, content, + "${{ steps.cascade-release-app-token.outputs.token || secrets.GITHUB_TOKEN }}") + + // No state mint step is injected when only the release seam is App-backed. + assert.NotContains(t, content, "cascade-state-app-token") +} + +// TestReleaseGenerator_StateAppTokenMints asserts the state seam mints its own +// step with the distinct state id in the finalize job. +func TestReleaseGenerator_StateAppTokenMints(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"prod"}, + StateTokenApp: &config.AppTokenSource{ + AppID: "CASCADE_APP_ID", + PrivateKey: "CASCADE_APP_PRIVATE_KEY", + }, + } + content, err := NewReleaseGenerator(cfg, "").Generate() + require.NoError(t, err) + + assert.Contains(t, content, "- name: Mint state app token") + assert.Contains(t, content, "id: cascade-state-app-token") + assert.Contains(t, content, "if: ${{ github.server_url == 'https://github.com' }}") + assert.Contains(t, content, + "${{ steps.cascade-state-app-token.outputs.token || secrets.GITHUB_TOKEN }}") +} + +// TestMintStep_FallbackPrefersConfiguredStaticToken asserts the fallback inlines +// a configured static token expression, not just GITHUB_TOKEN. +func TestMintStep_FallbackPrefersConfiguredStaticToken(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"prod"}, + ReleaseToken: "MY_RELEASE_PAT", + ReleaseTokenApp: &config.AppTokenSource{ + AppID: "CASCADE_APP_ID", + PrivateKey: "CASCADE_APP_PRIVATE_KEY", + }, + } + content, err := NewReleaseGenerator(cfg, "").Generate() + require.NoError(t, err) + + assert.Contains(t, content, + "${{ steps.cascade-release-app-token.outputs.token || secrets.MY_RELEASE_PAT }}") +} + +// TestResolveTokenRef_OffStateIdentity asserts the resolver returns today's +// static expression byte-for-byte when no App source is set. +func TestResolveTokenRef_OffStateIdentity(t *testing.T) { + cfg := &config.TrunkConfig{TrunkBranch: "main"} + assert.Equal(t, cfg.GetReleaseToken(), resolveReleaseTokenRef(cfg)) + assert.Equal(t, cfg.GetStateToken(), resolveStateTokenRef(cfg)) +} + +// 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) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"prod"}, + ReleaseTokenApp: &config.AppTokenSource{ + AppID: "CASCADE_APP_ID", + PrivateKey: "CASCADE_APP_PRIVATE_KEY", + }, + } + content, err := NewReleaseGenerator(cfg, "").Generate() + require.NoError(t, err) + assert.True(t, strings.Contains(content, "\n - name: Mint release app token\n"), + "mint step must be emitted at the 6-space step indent") +} + +// TestAppTokenSource_Actionlint runs actionlint over a release workflow that has +// both seams App-backed, proving the injected minting steps and the rewired +// token refs are valid GitHub Actions YAML. Skipped when actionlint is absent so +// the unit suite stays hermetic. +func TestAppTokenSource_Actionlint(t *testing.T) { + bin, err := exec.LookPath("actionlint") + if err != nil { + t.Skip("actionlint not installed") + } + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"prod"}, + ReleaseTokenApp: &config.AppTokenSource{ + AppID: "CASCADE_APP_ID", + PrivateKey: "CASCADE_APP_PRIVATE_KEY", + }, + StateTokenApp: &config.AppTokenSource{ + AppID: "CASCADE_APP_ID", + PrivateKey: "CASCADE_APP_PRIVATE_KEY", + }, + } + content, err := NewReleaseGenerator(cfg, "").Generate() + require.NoError(t, err) + + dir := t.TempDir() + wfDir := filepath.Join(dir, ".github", "workflows") + require.NoError(t, os.MkdirAll(wfDir, 0755)) + wfPath := filepath.Join(wfDir, "cascade-release.yaml") + require.NoError(t, os.WriteFile(wfPath, []byte(content), 0644)) + + gitInit := exec.Command("git", "init", "-q") + gitInit.Dir = dir + require.NoError(t, gitInit.Run(), "git init for actionlint project root") + writeReusableWorkflowStubs(t, dir, content) + + // Disable shellcheck: inline run: bodies trip style nits orthogonal to the + // minting step and token-ref structure this test governs. + cmd := exec.Command(bin, "-shellcheck=", wfPath) + cmd.Dir = dir + out, runErr := cmd.CombinedOutput() + assert.NoError(t, runErr, "actionlint reported issues:\n%s", string(out)) +} diff --git a/internal/generate/external.go b/internal/generate/external.go index 76f2c69..47eb3d3 100644 --- a/internal/generate/external.go +++ b/internal/generate/external.go @@ -34,14 +34,14 @@ func (g *ExternalUpdateGenerator) getCLIRef() string { // getReleaseTokenRef returns the token expression for release operations. func (g *ExternalUpdateGenerator) getReleaseTokenRef() string { - return g.config.GetReleaseToken() + return resolveReleaseTokenRef(g.config) } // 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 *ExternalUpdateGenerator) getStateTokenRef() string { - return g.config.GetStateToken() + return resolveStateTokenRef(g.config) } // getManifestFilePath returns the manifest file path for use in generated scripts. @@ -219,6 +219,7 @@ func (g *ExternalUpdateGenerator) writeJob(sb *strings.Builder) { sb.WriteString(" name: Update External State\n") sb.WriteString(" runs-on: ubuntu-latest\n") sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamRelease, seamState) // The receiver checks out with the state token (the push identity) so that // the manifest push is not blocked by branch protection rules on the primary // repo. fetch-depth: 0 is required for parity with orchestrate/promote/hotfix diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 6b996a4..51023a9 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -139,7 +139,7 @@ func NewGenerator(cfg *config.TrunkConfig, baseDir string) *Generator { // 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() + return resolveStateTokenRef(g.config) } // ownedJobTimeoutMinutes returns the timeout-minutes to emit on cascade-owned @@ -208,7 +208,7 @@ func (g *Generator) getCLIRef() string { // getReleaseTokenRef returns the token expression for release operations. // Users configure the full expression via release_token config option. func (g *Generator) getReleaseTokenRef() string { - return g.config.GetReleaseToken() + return resolveReleaseTokenRef(g.config) } // getManifestFilePath returns the manifest file path for use in generated scripts. @@ -816,6 +816,7 @@ func (g *Generator) writeSetupJob(sb *strings.Builder) { } sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamRelease) writeActionStep(sb, g.config, " ", actionCheckout) sb.WriteString(" with:\n") sb.WriteString(" fetch-depth: 0\n") @@ -1358,6 +1359,7 @@ func (g *Generator) writeFinalizeJob(sb *strings.Builder, sorted []string) { } sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamRelease, seamState) writeActionStep(sb, g.config, " ", actionCheckout) // Need full git history for changelog generation if g.config.ChangelogEnabled() { diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index c32a260..ba3e72f 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -60,7 +60,7 @@ func NewHotfixGenerator(cfg *config.TrunkConfig, baseDir string) *HotfixGenerato // so a hotfix into a repo with no configured state token records no state until // the operator supplies a trigger-capable token here. func (g *HotfixGenerator) getStateTokenRef() string { - return g.config.GetStateToken() + return resolveStateTokenRef(g.config) } // Enabled reports whether the hotfix workflow should be emitted. The workflow is @@ -283,11 +283,16 @@ func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) { // no state token is configured this degrades to GITHUB_TOKEN, in which case // post-hotfix automation (early check + finalize) requires the operator to // supply a trigger-capable state_token, matching the merge step's caveat. - fmt.Fprintf(sb, " GH_TOKEN: %s\n", g.getStateTokenRef()) + // This is a job-level env, where the steps.* context is not available, so it + // always binds the static state token. When a state-token App is configured + // the per-step consumers (the merge step below) carry the minted-token + // fallback themselves; this job-level default stays on the static token. + fmt.Fprintf(sb, " GH_TOKEN: %s\n", g.config.GetStateToken()) sb.WriteString(" COMMIT: ${{ github.event.inputs.commit }}\n") sb.WriteString(" TARGET_ENV: ${{ github.event.inputs.target_env }}\n") sb.WriteString(" BASE_SHA: ${{ needs.plan.outputs.base_sha }}\n") sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamState) writeActionStep(sb, g.config, " ", actionCheckout) sb.WriteString(" with:\n") sb.WriteString(" fetch-depth: 0\n") @@ -663,6 +668,7 @@ func (g *HotfixGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" FIX_SHA: ${{ needs.context.outputs.fix_sha }}\n") sb.WriteString(" BASE_SHA: ${{ needs.context.outputs.base_sha }}\n") sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamState) writeActionStep(sb, g.config, " ", actionCheckout) sb.WriteString(" with:\n") sb.WriteString(" fetch-depth: 0\n") diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 1a8ae2d..1e732f4 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -56,14 +56,14 @@ func (g *PromoteGenerator) getCLIRef() string { // getReleaseTokenRef returns the token expression for release operations. // Users configure the full expression via release_token config option. func (g *PromoteGenerator) getReleaseTokenRef() string { - return g.config.GetReleaseToken() + return resolveReleaseTokenRef(g.config) } // 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() + return resolveStateTokenRef(g.config) } // getManifestFilePath returns the manifest file path for use in generated scripts. @@ -657,6 +657,7 @@ func (g *PromoteGenerator) writePreflightJob(sb *strings.Builder) { } sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamRelease) writeActionStep(sb, g.config, " ", actionCheckout) sb.WriteString(" with:\n") sb.WriteString(" fetch-depth: 0\n") @@ -711,6 +712,7 @@ func (g *PromoteGenerator) writePromoteJob(sb *strings.Builder) { sb.WriteString(" if: ${{ github.event.inputs.dry_run != 'true' }}\n") sb.WriteString(" runs-on: ubuntu-latest\n") sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamRelease) writeActionStep(sb, g.config, " ", actionCheckout) sb.WriteString(" - name: Setup CLI\n") fmt.Fprintf(sb, " uses: stablekernel/cascade/.github/actions/setup-cli@%s\n", g.getCLIRef()) @@ -1069,6 +1071,7 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" if: always() && needs.preflight.result == 'success'\n") sb.WriteString(" runs-on: ubuntu-latest\n") sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamRelease, seamState) writeActionStep(sb, g.config, " ", actionCheckout) sb.WriteString(" with:\n") sb.WriteString(" fetch-depth: 0\n") diff --git a/internal/generate/release.go b/internal/generate/release.go index 9d92a1a..e87d440 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -39,7 +39,7 @@ func (g *ReleaseGenerator) getCLIRef() string { // getReleaseTokenRef returns the token expression for release operations. // Users configure the full expression via release_token config option. func (g *ReleaseGenerator) getReleaseTokenRef() string { - return g.config.GetReleaseToken() + return resolveReleaseTokenRef(g.config) } // getStateTokenRef returns the token expression used to write manifest state to @@ -47,7 +47,7 @@ func (g *ReleaseGenerator) getReleaseTokenRef() string { // 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() + return resolveStateTokenRef(g.config) } // getManifestFilePath returns the manifest file path for use in generated scripts. @@ -177,6 +177,7 @@ func (g *ReleaseGenerator) writePreflightJob(sb *strings.Builder) { sb.WriteString(" source_version: ${{ steps.validate.outputs.source_version }}\n") sb.WriteString(" semver_tag: ${{ steps.semver.outputs.semver_tag }}\n") sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamRelease) writeActionStep(sb, g.config, " ", actionCheckout) sb.WriteString(" with:\n") sb.WriteString(" fetch-depth: 0\n") @@ -298,6 +299,7 @@ func (g *ReleaseGenerator) writeReleaseJob(sb *strings.Builder) { sb.WriteString(" if: ${{ github.event.inputs.dry_run != 'true' }}\n") sb.WriteString(" runs-on: ubuntu-latest\n") sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamRelease) writeActionStep(sb, g.config, " ", actionCheckout) sb.WriteString(" with:\n") sb.WriteString(" fetch-depth: 0\n") @@ -377,6 +379,7 @@ func (g *ReleaseGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" if: always() && needs.preflight.result == 'success' && github.event.inputs.release_action == 'release'\n") sb.WriteString(" runs-on: ubuntu-latest\n") sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamState) writeActionStep(sb, g.config, " ", actionCheckout) // Update latest_release state diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index abbeeef..63f5b20 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -52,13 +52,13 @@ func (g *RollbackGenerator) getCLIRef() string { // getReleaseTokenRef returns the token expression for deploy/release operations. func (g *RollbackGenerator) getReleaseTokenRef() string { - return g.config.GetReleaseToken() + return resolveReleaseTokenRef(g.config) } // getStateTokenRef returns the token expression used to write manifest state to // the trunk branch. func (g *RollbackGenerator) getStateTokenRef() string { - return g.config.GetStateToken() + return resolveStateTokenRef(g.config) } // getManifestFilePath returns the repo-relative manifest path for use in the @@ -201,6 +201,7 @@ func (g *RollbackGenerator) writePreflightJob(sb *strings.Builder) { sb.WriteString(" target_version: ${{ steps.preflight.outputs.target_version }}\n") sb.WriteString(" can_proceed: ${{ steps.preflight.outputs.can_proceed }}\n") sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamRelease) g.writeSetupCLI(sb) sb.WriteString(" - name: Resolve Target\n") @@ -287,6 +288,7 @@ func (g *RollbackGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" if: always() && needs.preflight.result == 'success' && github.event.inputs.dry_run != 'true'\n") sb.WriteString(" runs-on: ubuntu-latest\n") sb.WriteString(" steps:\n") + writeMintSteps(sb, g.config, " ", seamRelease, seamState) g.writeSetupCLI(sb) sb.WriteString(" - name: Finalize Rollback\n") diff --git a/internal/generate/token_source.go b/internal/generate/token_source.go new file mode 100644 index 0000000..eb98b90 --- /dev/null +++ b/internal/generate/token_source.go @@ -0,0 +1,112 @@ +package generate + +import ( + "strings" + + "github.com/stablekernel/cascade/internal/config" +) + +// Shared step ids for the App-token minting steps. One id per seam so a job that +// consumes both the release and state tokens can carry both mint steps without +// collision, and every consuming ref can name the matching minted output. +const ( + releaseAppTokenStepID = "cascade-release-app-token" + stateAppTokenStepID = "cascade-state-app-token" +) + +// appTokenServerGuard keeps the minting step on real GitHub only. On act/gitea +// the step is skipped and its output is empty, so the consuming ref falls back +// to the static token. This matches the existing real-vs-gitea split that keys +// on GITHUB_SERVER_URL (see state_write.go). +const appTokenServerGuard = "${{ github.server_url == 'https://github.com' }}" + +// stripTokenExprWrapper returns the inner expression of a wrapped GitHub Actions +// expression, dropping the outer "${{" / "}}" so it can be inlined inside a +// larger "${{ ... }}". A value that is not wrapped is returned trimmed and +// unchanged, which keeps the fallback resilient to any future ref shape. +func stripTokenExprWrapper(expr string) string { + trimmed := strings.TrimSpace(expr) + if strings.HasPrefix(trimmed, "${{") && strings.HasSuffix(trimmed, "}}") { + inner := trimmed[len("${{") : len(trimmed)-len("}}")] + return strings.TrimSpace(inner) + } + return trimmed +} + +// resolveReleaseTokenRef returns the release-token expression a consuming step +// must use. With no release App identity it returns today's value verbatim, so +// OFF-state output is byte-identical. With an App identity it returns a fallback +// expression that prefers the minted token on real GitHub and the static token +// on act/gitea. +func resolveReleaseTokenRef(cfg *config.TrunkConfig) string { + static := cfg.GetReleaseToken() + if !cfg.HasReleaseTokenApp() { + return static + } + return mintedTokenFallbackRef(releaseAppTokenStepID, static) +} + +// resolveStateTokenRef mirrors resolveReleaseTokenRef for the state-token seam. +func resolveStateTokenRef(cfg *config.TrunkConfig) string { + static := cfg.GetStateToken() + if !cfg.HasStateTokenApp() { + return static + } + return mintedTokenFallbackRef(stateAppTokenStepID, static) +} + +// mintedTokenFallbackRef builds "${{ steps..outputs.token || }}", +// inlining the static token's inner expression so the wrapped form stays valid. +func mintedTokenFallbackRef(stepID, staticExpr string) string { + return "${{ steps." + stepID + ".outputs.token || " + stripTokenExprWrapper(staticExpr) + " }}" +} + +// tokenSeam identifies which App-token mint step(s) a job consumes. +type tokenSeam int + +const ( + // seamRelease marks a job that consumes the release token. + seamRelease tokenSeam = iota + // seamState marks a job that consumes the state token. + seamState +) + +// writeMintSteps writes the App-token minting step(s) for the seams a job +// consumes, at the given indent (spaces before "- name:"). It is a no-op for any +// seam without a configured App identity, so OFF-state output is byte-identical: +// nothing, not even a blank line, is emitted. Call it immediately after a job's +// "steps:" line. Pass each seam the job consumes; a job consuming both tokens +// passes both and gets both mint steps (the ids are distinct). +func writeMintSteps(sb *strings.Builder, cfg *config.TrunkConfig, indent string, seams ...tokenSeam) { + for _, seam := range seams { + switch seam { + case seamRelease: + if cfg.HasReleaseTokenApp() { + writeMintStep(sb, cfg, indent, + "Mint release app token", releaseAppTokenStepID, + cfg.GetReleaseTokenAppID(), cfg.GetReleaseTokenAppPrivateKey()) + } + case seamState: + if cfg.HasStateTokenApp() { + writeMintStep(sb, cfg, indent, + "Mint state app token", stateAppTokenStepID, + cfg.GetStateTokenAppID(), cfg.GetStateTokenAppPrivateKey()) + } + } + } +} + +// writeMintStep writes a single actions/create-github-app-token step. The +// with-indent is the step indent plus two spaces; the key indent under with: is +// four spaces deeper, matching the surrounding generated YAML. +func writeMintStep(sb *strings.Builder, cfg *config.TrunkConfig, indent, name, id, appID, privateKey string) { + body := indent + " " + with := indent + " " + sb.WriteString(indent + "- name: " + name + "\n") + sb.WriteString(body + "id: " + id + "\n") + sb.WriteString(body + "if: " + appTokenServerGuard + "\n") + sb.WriteString(body + "uses: " + actionRef(cfg, actionCreateAppToken) + "\n") + sb.WriteString(body + "with:\n") + sb.WriteString(with + "app-id: " + appID + "\n") + sb.WriteString(with + "private-key: " + privateKey + "\n") +} diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 21b76c8..23a16d6 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -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, @@ -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\")." diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 21b76c8..23a16d6 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -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, @@ -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\")."