From 156a027e08e4dfbd6bf47cf4897493137c701c9d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Tue, 21 Jul 2026 13:05:37 -0400 Subject: [PATCH] feat(generate): add --cli-install=binary for third-party-action-free CLI install generate-workflow gains a --cli-install flag (action|binary, default action). In binary mode every generated setup-cli step is a self-contained run: step that uses no third-party action: it resolves the release tag, detects OS/arch, installs a checksum-verified cosign by direct download, and runs the same mandatory sha256 gate and keyless cosign verification the composite action performs. The verify logic lives in one install.sh, embedded and emitted inline, so both modes share a single contract. This lets a repo adopt cascade without an organization Actions allowlist entry. Default action mode is byte-identical to before (zero drift). Signed-off-by: Joshua Temple --- README.md | 2 +- docs/src/content/docs/reference/cli.md | 22 ++ .../src/content/docs/start/getting-started.md | 6 + internal/generate/command.go | 22 +- internal/generate/drift_check.go | 8 +- internal/generate/external.go | 2 + internal/generate/generator.go | 3 + internal/generate/hotfix.go | 8 +- internal/generate/merge_queue.go | 8 +- internal/generate/pr_preview.go | 8 +- internal/generate/promote.go | 4 + internal/generate/reconcile_companion.go | 17 +- internal/generate/release.go | 3 + internal/generate/rollback.go | 2 + internal/generate/setup_cli.go | 172 +++++++++++++-- .../generate/setup_cli_binary_preamble.sh | 96 ++++++++ internal/generate/setup_cli_binary_test.go | 208 ++++++++++++++++++ internal/generate/setup_cli_install.sh | 153 +++++++++++++ internal/generate/validate_check.go | 8 +- 19 files changed, 714 insertions(+), 38 deletions(-) create mode 100644 internal/generate/setup_cli_binary_preamble.sh create mode 100644 internal/generate/setup_cli_binary_test.go create mode 100755 internal/generate/setup_cli_install.sh diff --git a/README.md b/README.md index 8b524dea..48fcbf12 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Read [How Cascade works](https://stablekernel.github.io/cascade/start/how-it-wor go install github.com/stablekernel/cascade/cmd/cascade@latest ``` -See [Getting started](https://stablekernel.github.io/cascade/start/getting-started/) for the pinned-version install and the `setup-cli` action, both of which most teams should use instead of a bare `@latest` install. +See [Getting started](https://stablekernel.github.io/cascade/start/getting-started/) for the pinned-version install and the `setup-cli` action, both of which most teams should use instead of a bare `@latest` install. If your organization restricts Actions to an allowlist, generate with `--cli-install=binary` so the generated workflows install the CLI inline with no third-party action (same signed-release verification); see the [`--cli-install` flag](https://stablekernel.github.io/cascade/reference/cli/#installing-the-cli-in-generated-workflows). Write a manifest, write your build and deploy callbacks, then generate. Callbacks must exist first: the generator reads their `workflow_call` outputs to wire the rest. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 49255432..14e4a0c4 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -135,6 +135,28 @@ cascade generate-workflow | `--push`, `-p` | bool | false | Push (implies `--commit`) | | `--orchestrate-only` | bool | false | Only generate the orchestrate workflow | | `--promote-only` | bool | false | Only generate the promote workflow | +| `--cli-install` | string | `action` | How generated workflows install the cascade CLI: `action` or `binary` | + +#### Installing the CLI in generated workflows + +Every generated workflow installs the cascade CLI before it runs. `--cli-install` +selects how: + +- `action` (default): the workflow uses the `setup-cli` composite action + (`stablekernel/cascade/.github/actions/setup-cli`). This is unchanged from prior + releases. +- `binary`: the workflow installs the CLI with a self-contained step that uses no + third-party action at all. It resolves the release tag, detects the runner + OS/arch, installs a checksum-verified [cosign](https://github.com/sigstore/cosign) + by direct download, and runs the same mandatory sha256 gate and keyless cosign + verification the action performs. Both modes share one install script, so the + authenticity guarantees are identical - see + [Verifying cascade releases](/cascade/security/#verifying-cascade-releases). + +Use `binary` when your organization restricts GitHub Actions to an allowlist: +binary mode needs no allowlist entry for a third-party action, because it uses +none. Both modes install the same signed release binary with the same +verification; the choice is purely about how that install step is expressed. #### Generated workflow features diff --git a/docs/src/content/docs/start/getting-started.md b/docs/src/content/docs/start/getting-started.md index b8392704..c574f981 100644 --- a/docs/src/content/docs/start/getting-started.md +++ b/docs/src/content/docs/start/getting-started.md @@ -41,6 +41,12 @@ You rarely need to invoke Cascade manually in CI: generated workflows install it The action downloads the release archive, extracts it, and puts `cascade` on `PATH`. +If your organization restricts GitHub Actions to an allowlist, you can instead +have `generate-workflow` emit a self-contained install that uses no third-party +action: run it with `--cli-install=binary`. The generated workflows then install +the CLI inline, with the same signed-release verification, and need no allowlist +entry. See [`--cli-install`](/cascade/reference/cli/#installing-the-cli-in-generated-workflows). + ## Scaffold with `cascade init` The fastest path to a working manifest is `cascade init`. It renders the manifest and callback stubs, verifies them through the real generator, and writes them into your repository: diff --git a/internal/generate/command.go b/internal/generate/command.go index a6b62532..4013f156 100644 --- a/internal/generate/command.go +++ b/internal/generate/command.go @@ -27,6 +27,7 @@ func NewCommand() *cobra.Command { var orchestrateOnly bool var promoteOnly bool var ownRepo bool + var cliInstall string cmd := &cobra.Command{ Use: "generate-workflow", @@ -45,6 +46,10 @@ the "ci" key by default. Use --manifest-key to change the key name.`, if push { commit = true } + installMode, err := parseCLIInstallMode(cliInstall) + if err != nil { + return err + } opts := generateOptions{ configPath: configPath, manifestKey: manifestKey, @@ -59,6 +64,7 @@ the "ci" key by default. Use --manifest-key to change the key name.`, orchestrateOnly: orchestrateOnly, promoteOnly: promoteOnly, ownRepo: ownRepo, + cliInstallMode: installMode, } return runGenerateWorkflow(opts) }, @@ -77,6 +83,7 @@ the "ci" key by default. Use --manifest-key to change the key name.`, cmd.Flags().BoolVar(&orchestrateOnly, "orchestrate-only", false, "Only generate orchestrate.yaml") cmd.Flags().BoolVar(&promoteOnly, "promote-only", false, "Only generate promote.yaml") cmd.Flags().BoolVar(&ownRepo, "own-repo", false, "Generate cascade's own-repo release-plumbing variant (tag-only manage-release, non-triggering tag-create). Maintainer-only; never used by a downstream manifest.") + cmd.Flags().StringVar(&cliInstall, "cli-install", "action", "How generated workflows install the cascade CLI: \"action\" (setup-cli composite action, default) or \"binary\" (self-contained install with no third-party action, so no org Actions allowlist entry is needed)") return cmd } @@ -96,6 +103,7 @@ type generateOptions struct { orchestrateOnly bool promoteOnly bool ownRepo bool + cliInstallMode cliInstallMode } func runGenerateWorkflow(opts generateOptions) error { @@ -161,6 +169,7 @@ func runGenerateWorkflow(opts generateOptions) error { return fmt.Errorf("planning orchestrate workflow: %w", err) } for _, t := range orchTargets { + t.Gen.setInstallMode(opts.cliInstallMode) for _, w := range t.Gen.Validate() { fmt.Fprintf(os.Stderr, "%s\n", w) } @@ -203,7 +212,9 @@ func runGenerateWorkflow(opts generateOptions) error { if generatePromote { if cfg.IsSingleEnvironment() { // Single-environment projects get a simpler Release workflow. - content, err := NewReleaseGenerator(cfg, baseDir).Generate() + relGen := NewReleaseGenerator(cfg, baseDir) + relGen.setInstallMode(opts.cliInstallMode) + content, err := relGen.Generate() if err != nil { return fmt.Errorf("generating release workflow: %w", err) } @@ -225,6 +236,7 @@ func runGenerateWorkflow(opts generateOptions) error { return fmt.Errorf("planning promote workflow: %w", perr) } for _, t := range promoteTargets { + t.Gen.setInstallMode(opts.cliInstallMode) content, err := t.Gen.Generate() if err != nil { return fmt.Errorf("generating promote workflow %s: %w", t.Path, err) @@ -248,6 +260,7 @@ func runGenerateWorkflow(opts generateOptions) error { // Generate external-update workflow for primary repos if cfg.IsPrimary() { externalGen := NewExternalUpdateGenerator(cfg, baseDir) + externalGen.setInstallMode(opts.cliInstallMode) content, err := externalGen.Generate() if err != nil { return fmt.Errorf("generating external-update workflow: %w", err) @@ -272,6 +285,7 @@ func runGenerateWorkflow(opts generateOptions) error { // Generate the opt-in manifest-validation PR check (validate_check.enabled). validateCheckGen := NewValidateCheckGenerator(cfg, baseDir) + validateCheckGen.setInstallMode(opts.cliInstallMode) if validateCheckGen.Enabled() { content, err := validateCheckGen.Generate() if err != nil { @@ -292,6 +306,7 @@ func runGenerateWorkflow(opts generateOptions) error { // Generate the opt-in merge-queue validation lane (merge_queue.enabled). mergeQueueGen := NewMergeQueueGenerator(cfg, baseDir) + mergeQueueGen.setInstallMode(opts.cliInstallMode) if mergeQueueGen.Enabled() { content, err := mergeQueueGen.Generate() if err != nil { @@ -318,6 +333,7 @@ func runGenerateWorkflow(opts generateOptions) error { return fmt.Errorf("planning hotfix workflow: %w", err) } for _, t := range hfTargets { + t.Gen.setInstallMode(opts.cliInstallMode) content, err := t.Gen.Generate() if err != nil { return fmt.Errorf("generating hotfix workflow %s: %w", t.Path, err) @@ -343,6 +359,7 @@ func runGenerateWorkflow(opts generateOptions) error { return fmt.Errorf("planning rollback workflow: %w", err) } for _, t := range rbTargets { + t.Gen.setInstallMode(opts.cliInstallMode) content, err := t.Gen.Generate() if err != nil { return fmt.Errorf("generating rollback workflow %s: %w", t.Path, err) @@ -363,6 +380,7 @@ func runGenerateWorkflow(opts generateOptions) error { // disabled pr_preview emits nothing, so existing manifests are unaffected. if cfg.PRPreview.IsEnabled() { previewGen := NewPRPreviewGenerator(cfg, baseDir) + previewGen.setInstallMode(opts.cliInstallMode) content, err := previewGen.Generate() if err != nil { return fmt.Errorf("generating pr-preview workflow: %w", err) @@ -386,6 +404,7 @@ func runGenerateWorkflow(opts generateOptions) error { // drift_check emits nothing, so existing manifests are unaffected. The // comment companion is emitted only when drift_check.comment is also set. driftGen := NewDriftCheckGenerator(cfg, baseDir) + driftGen.setInstallMode(opts.cliInstallMode) if driftGen.Enabled() { content, err := driftGen.Generate() if err != nil { @@ -426,6 +445,7 @@ func runGenerateWorkflow(opts generateOptions) error { // the pull_request detector plus its workflow_run companion. Absent or // disabled reconcile emits nothing, so existing manifests are unaffected. reconcileGen := NewReconcileGenerator(cfg, baseDir) + reconcileGen.setInstallMode(opts.cliInstallMode) if reconcileGen.Enabled() { content, err := reconcileGen.Generate() if err != nil { diff --git a/internal/generate/drift_check.go b/internal/generate/drift_check.go index 4a3dd904..05a94f84 100644 --- a/internal/generate/drift_check.go +++ b/internal/generate/drift_check.go @@ -41,6 +41,7 @@ const driftCommentWorkflowName = "Cascade Drift Comment" // pull_requests array, or a head-SHA lookup for fork PRs), never from the // fork-controlled artifact, so a fork cannot redirect the comment at another PR. type DriftCheckGenerator struct { + installModeHolder config *config.TrunkConfig baseDir string } @@ -140,9 +141,10 @@ func (g *DriftCheckGenerator) writeCheckJob(sb *strings.Builder) { // github.token is the built-in Actions token, sufficient to authenticate // gh release download against the public stablekernel/cascade repository. writeSetupCLIStep(sb, setupCLIStep{ - ref: g.getCLIRef(), - version: g.config.GetCLIVersion(), - token: "${{ github.token }}", + installMode: g.installMode, + ref: g.getCLIRef(), + version: g.config.GetCLIVersion(), + token: "${{ github.token }}", }) sb.WriteString("\n") diff --git a/internal/generate/external.go b/internal/generate/external.go index e643715b..bc3bbbc3 100644 --- a/internal/generate/external.go +++ b/internal/generate/external.go @@ -9,6 +9,7 @@ import ( // ExternalUpdateGenerator handles external-update workflow generation type ExternalUpdateGenerator struct { + installModeHolder config *config.TrunkConfig baseDir string } @@ -231,6 +232,7 @@ func (g *ExternalUpdateGenerator) writeJob(sb *strings.Builder) { // Setup CLI writeSetupCLIStep(sb, setupCLIStep{ + installMode: g.installMode, ref: g.getCLIRef(), version: g.config.GetCLIVersion(), token: g.getReleaseTokenRef(), diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 9696a274..bd3f60b1 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -124,6 +124,7 @@ func writeGitConfigSteps(sb *strings.Builder, cfg *config.TrunkConfig, indent st // Generator handles workflow file generation type Generator struct { + installModeHolder config *config.TrunkConfig baseDir string outputs map[string][]string // callback name -> output names @@ -1070,6 +1071,7 @@ func (g *Generator) writeSetupJob(sb *strings.Builder) { // Setup CLI writeSetupCLIStep(sb, setupCLIStep{ + installMode: g.installMode, ref: g.getCLIRef(), version: g.config.GetCLIVersion(), token: g.getReleaseTokenRef(), @@ -2302,6 +2304,7 @@ func (g *Generator) writeFinalizeCLIBootstrap(sb *strings.Builder) { // it is emitted as the step's if: guard. func (g *Generator) writeFinalizePinnedCLI(sb *strings.Builder, cond string) { writeSetupCLIStep(sb, setupCLIStep{ + installMode: g.installMode, ref: g.getCLIRef(), version: g.config.GetCLIVersion(), token: g.getReleaseTokenRef(), diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index 6cb8aa24..14f3fc97 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -39,6 +39,7 @@ const hotfixConflictLabel = "cascade-hotfix-conflict" // when two or more environments are declared, because a single-environment // pipeline has no intermediate target to hotfix onto. type HotfixGenerator struct { + installModeHolder config *config.TrunkConfig baseDir string @@ -920,9 +921,10 @@ func (g *HotfixGenerator) writeSetupCLI(sb *strings.Builder) { // github.token is the built-in Actions token, sufficient to authenticate // gh release download against the public stablekernel/cascade repository. writeSetupCLIStep(sb, setupCLIStep{ - ref: g.getCLIRef(), - version: g.config.GetCLIVersion(), - token: "${{ github.token }}", + installMode: g.installMode, + ref: g.getCLIRef(), + version: g.config.GetCLIVersion(), + token: "${{ github.token }}", }) } diff --git a/internal/generate/merge_queue.go b/internal/generate/merge_queue.go index 12e7dace..8ec1df24 100644 --- a/internal/generate/merge_queue.go +++ b/internal/generate/merge_queue.go @@ -21,6 +21,7 @@ import ( // side-effecting orchestrate workflow on a speculative merge-queue build with no // gh-readonly-queue guard; merge_queue.enabled emits this read-only lane instead. type MergeQueueGenerator struct { + installModeHolder config *config.TrunkConfig baseDir string } @@ -105,9 +106,10 @@ func (g *MergeQueueGenerator) writeJob(sb *strings.Builder) { // github.token is the built-in Actions token, sufficient to authenticate // gh release download against the public stablekernel/cascade repository. writeSetupCLIStep(sb, setupCLIStep{ - ref: g.getCLIRef(), - version: g.config.GetCLIVersion(), - token: "${{ github.token }}", + installMode: g.installMode, + ref: g.getCLIRef(), + version: g.config.GetCLIVersion(), + token: "${{ github.token }}", }) // Validity gate: lint --json reports validity in its JSON output, so gate diff --git a/internal/generate/pr_preview.go b/internal/generate/pr_preview.go index 27e5311f..cb20d2f8 100644 --- a/internal/generate/pr_preview.go +++ b/internal/generate/pr_preview.go @@ -22,6 +22,7 @@ const prPreviewMarker = "" // dry_run is enforced (--dry-run is always passed, never sourced from an input) // so the preview can never create a release, write state, or trigger a deploy. type PRPreviewGenerator struct { + installModeHolder config *config.TrunkConfig baseDir string } @@ -118,9 +119,10 @@ func (g *PRPreviewGenerator) writeJob(sb *strings.Builder) { // authenticate gh release download against the public stablekernel/cascade // repository and requires no adopter configuration. writeSetupCLIStep(sb, setupCLIStep{ - ref: g.getCLIRef(), - version: g.config.GetCLIVersion(), - token: "${{ github.token }}", + installMode: g.installMode, + ref: g.getCLIRef(), + version: g.config.GetCLIVersion(), + token: "${{ github.token }}", }) sb.WriteString("\n") diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 6ed21275..b6330bdb 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -13,6 +13,7 @@ import ( // PromoteGenerator handles promote workflow generation type PromoteGenerator struct { + installModeHolder config *config.TrunkConfig baseDir string inputs map[string][]string // deploy name -> input names @@ -666,6 +667,7 @@ func (g *PromoteGenerator) writePreflightJob(sb *strings.Builder) { // Setup CLI writeSetupCLIStep(sb, setupCLIStep{ + installMode: g.installMode, ref: g.getCLIRef(), version: g.config.GetCLIVersion(), token: g.getReleaseTokenRef(), @@ -719,6 +721,7 @@ func (g *PromoteGenerator) writePromoteJob(sb *strings.Builder) { writeMintSteps(sb, g.config, " ", seamRelease) writeActionStep(sb, g.config, " ", actionCheckout) writeSetupCLIStep(sb, setupCLIStep{ + installMode: g.installMode, ref: g.getCLIRef(), version: g.config.GetCLIVersion(), token: g.getReleaseTokenRef(), @@ -1112,6 +1115,7 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" with:\n") sb.WriteString(" fetch-depth: 0\n") writeSetupCLIStep(sb, setupCLIStep{ + installMode: g.installMode, ref: g.getCLIRef(), version: g.config.GetCLIVersion(), token: g.getReleaseTokenRef(), diff --git a/internal/generate/reconcile_companion.go b/internal/generate/reconcile_companion.go index d3c96881..9e1e7373 100644 --- a/internal/generate/reconcile_companion.go +++ b/internal/generate/reconcile_companion.go @@ -51,6 +51,7 @@ const reconcileFollowupBranchExpr = "cascade-reconcile/pr-${{ steps.resolve.outp // as data, runs the pinned cascade binary to adopt the bump into the manifest, // and pushes (or opens a followup PR) with the trigger-capable state token. type ReconcileGenerator struct { + installModeHolder config *config.TrunkConfig baseDir string ownRepo bool @@ -147,9 +148,10 @@ func (g *ReconcileGenerator) writeCheckJob(sb *strings.Builder) { // github.token is the built-in Actions token, sufficient to authenticate // gh release download against the public stablekernel/cascade repository. writeSetupCLIStep(sb, setupCLIStep{ - ref: g.getCLIRef(), - version: g.config.GetCLIVersion(), - token: "${{ github.token }}", + installMode: g.installMode, + ref: g.getCLIRef(), + version: g.config.GetCLIVersion(), + token: "${{ github.token }}", }) sb.WriteString("\n") @@ -413,10 +415,11 @@ func (g *ReconcileGenerator) writeCompanionCheckoutStep(sb *strings.Builder) { // run` off the repository's own (possibly malicious) source tree. func (g *ReconcileGenerator) writeCompanionSetupCLIStep(sb *strings.Builder) { writeSetupCLIStep(sb, setupCLIStep{ - ref: g.getCLIRef(), - version: g.config.GetCLIVersion(), - token: "${{ github.token }}", - ifExpr: "steps.resolve.outputs.relevant == 'true'", + installMode: g.installMode, + ref: g.getCLIRef(), + version: g.config.GetCLIVersion(), + token: "${{ github.token }}", + ifExpr: "steps.resolve.outputs.relevant == 'true'", }) sb.WriteString("\n") } diff --git a/internal/generate/release.go b/internal/generate/release.go index 577c046b..abebb4be 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -9,6 +9,7 @@ import ( // ReleaseGenerator handles release workflow generation for single-environment projects type ReleaseGenerator struct { + installModeHolder config *config.TrunkConfig baseDir string } @@ -232,6 +233,7 @@ func (g *ReleaseGenerator) writePreflightJob(sb *strings.Builder) { // Check for breaking changes writeSetupCLIStep(sb, setupCLIStep{ + installMode: g.installMode, ref: g.getCLIRef(), version: g.config.GetCLIVersion(), token: g.getReleaseTokenRef(), @@ -316,6 +318,7 @@ func (g *ReleaseGenerator) writeReleaseJob(sb *strings.Builder) { // Setup CLI writeSetupCLIStep(sb, setupCLIStep{ + installMode: g.installMode, ref: g.getCLIRef(), version: g.config.GetCLIVersion(), token: g.getReleaseTokenRef(), diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index ea77978c..513367ec 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -18,6 +18,7 @@ import ( // path. The generator is gated on the configured environment count: it // emits only when at least one environment is declared. type RollbackGenerator struct { + installModeHolder config *config.TrunkConfig baseDir string @@ -306,6 +307,7 @@ func (g *RollbackGenerator) writeSetupCLI(sb *strings.Builder) { sb.WriteString(" with:\n") sb.WriteString(" fetch-depth: 0\n") writeSetupCLIStep(sb, setupCLIStep{ + installMode: g.installMode, ref: g.getCLIRef(), version: g.config.GetCLIVersion(), token: g.getReleaseTokenRef(), diff --git a/internal/generate/setup_cli.go b/internal/generate/setup_cli.go index 9cbddf5a..48a7e848 100644 --- a/internal/generate/setup_cli.go +++ b/internal/generate/setup_cli.go @@ -1,35 +1,117 @@ package generate import ( + _ "embed" "fmt" "strings" ) +// setup_cli_install.sh is a byte-identical copy of the composite action's +// install script (.github/actions/setup-cli/install.sh). go:embed cannot reach +// a path outside the package directory (no ".." references), so the canonical +// script is copied here and TestSetupCLIInstallScript_MatchesActionCopy guards +// the two against drift. Binary mode writes these exact bytes to disk and runs +// them, so both install modes share one verify contract. +// +//go:embed setup_cli_install.sh +var setupCLIInstallScript string + +// setup_cli_binary_preamble.sh resolves the version, detects OS/arch, and +// installs a checksum-verified cosign, then hands off to the embedded +// install.sh. Binary mode emits it inline so no third-party action is required. +// +//go:embed setup_cli_binary_preamble.sh +var setupCLIBinaryPreamble string + +// cliInstallMode selects how a generator emits the "Setup CLI" step. The zero +// value is the default action mode, so a generator that never has a mode set +// emits byte-identical output to before this option existed. +type cliInstallMode int + +const ( + // cliInstallModeAction installs the CLI through the setup-cli composite + // action (uses: stablekernel/cascade/.github/actions/setup-cli@). This + // is the default and the zero value. + cliInstallModeAction cliInstallMode = iota + // cliInstallModeBinary installs the CLI with a self-contained run: step that + // uses no third-party action: it resolves the version, detects OS/arch, + // installs a checksum-verified cosign by direct download, and runs the same + // mandatory sha256 gate and keyless cosign verification the action's + // install.sh performs. It lets a repo adopt cascade without an organization + // Actions allowlist entry for a third-party action. + cliInstallModeBinary +) + +// String renders the mode as its flag spelling, so error messages and help echo +// the value a user would pass. +func (m cliInstallMode) String() string { + if m == cliInstallModeBinary { + return "binary" + } + return "action" +} + +// parseCLIInstallMode maps a --cli-install flag value to a cliInstallMode. Only +// "action" (default) and "binary" are valid; anything else is rejected loudly so +// a typo does not silently fall back to a mode the user did not ask for. +func parseCLIInstallMode(s string) (cliInstallMode, error) { + switch s { + case "", "action": + return cliInstallModeAction, nil + case "binary": + return cliInstallModeBinary, nil + default: + return cliInstallModeAction, fmt.Errorf("invalid --cli-install %q: expected \"action\" or \"binary\"", s) + } +} + +// installModeHolder carries the CLI install mode a generator threads into its +// setup-cli emission. Embedding it gives every generator the field and its +// setter without duplicating either across the dozen generator types. +type installModeHolder struct { + installMode cliInstallMode +} + +// setInstallMode records the CLI install mode for a generator. The generate +// command sets it from the --cli-install flag; unset leaves the default action +// mode, which keeps output byte-identical. +func (h *installModeHolder) setInstallMode(mode cliInstallMode) { h.installMode = mode } + // setupCLIStep describes a single emission of the "Setup CLI" step that installs -// the cascade binary through the setup-cli composite action. Every generator -// routes its setup-cli emission through writeSetupCLIStep so the action -// reference and its inputs have exactly one source. +// the cascade binary. Every generator routes its setup-cli emission through +// writeSetupCLIStep so the install logic has exactly one source. type setupCLIStep struct { - // ref is the git ref spliced after "setup-cli@". Callers pass the result of - // their own getCLIRef helper so the ref-resolution logic stays unchanged. + // ref is the git ref spliced after "setup-cli@" in action mode. Callers pass + // the result of their own getCLIRef helper so the ref-resolution logic stays + // unchanged. It is unused in binary mode. ref string - // version is the value of the action's "version" input, typically the result + // version is the value of the action's "version" input (action mode) or the + // CASCADE_CLI_VERSION the binary-mode preamble resolves. Typically the result // of TrunkConfig.GetCLIVersion. version string - // token is the value of the action's "token" input. + // token is the value of the action's "token" input (action mode) or the + // GH_TOKEN the binary-mode steps use for gh (binary mode). token string - // tokenBeforeVersion emits the token input above the version input, matching - // the release-path ordering. When false, version is emitted first. + // tokenBeforeVersion emits the token input above the version input in action + // mode, matching the release-path ordering. When false, version is emitted + // first. It has no effect in binary mode. tokenBeforeVersion bool - // ifExpr, when non-empty, adds a step-level "if:" guard above the uses line. + // ifExpr, when non-empty, adds a step-level "if:" guard above the step. ifExpr string + // installMode selects action (default) or binary emission. + installMode cliInstallMode } -// writeSetupCLIStep emits the "Setup CLI" step that installs the cascade binary -// via the setup-cli composite action. It is the single canonical emitter for the -// action reference (`uses: ...setup-cli@`) and the `version` input, so any -// future change to how the CLI is installed has one place to branch. +// writeSetupCLIStep emits the "Setup CLI" step that installs the cascade binary. +// It is the single canonical emitter, so any change to how the CLI is installed +// has one place to branch. In the default action mode it emits the composite +// action reference; in binary mode it emits a self-contained, third-party-free +// install that preserves the same sha256 and cosign-keyless guarantees. func writeSetupCLIStep(sb *strings.Builder, step setupCLIStep) { + if step.installMode == cliInstallModeBinary { + writeSetupCLIStepBinary(sb, step) + return + } sb.WriteString(" - name: Setup CLI\n") if step.ifExpr != "" { fmt.Fprintf(sb, " if: %s\n", step.ifExpr) @@ -44,3 +126,65 @@ func writeSetupCLIStep(sb *strings.Builder, step setupCLIStep) { fmt.Fprintf(sb, " version: %s\n", step.version) fmt.Fprintf(sb, " token: %s\n", step.token) } + +// binaryStepIndent is the indentation of the run: block body for a job step: six +// spaces to the "- name:", eight to its keys, ten to the script lines. YAML +// strips this common indent from a literal block scalar, so the shell receives +// the embedded scripts exactly as authored. +const binaryStepIndent = " " + +// writeSetupCLIStepBinary emits the self-contained, third-party-free install. +// The caller's token flows through GH_TOKEN and the version through +// CASCADE_CLI_VERSION (both via env, never spliced into the run: script, so no +// ${{ }} appears inside the script and real GitHub does not reject it at parse). +// The step writes the embedded install.sh to a temp file and runs the preamble, +// which resolves the tag, detects OS/arch, installs a verified cosign, and execs +// install.sh for the mandatory sha256 gate and keyless cosign verification. +func writeSetupCLIStepBinary(sb *strings.Builder, step setupCLIStep) { + sb.WriteString(" - name: Setup CLI\n") + if step.ifExpr != "" { + fmt.Fprintf(sb, " if: %s\n", step.ifExpr) + } + sb.WriteString(" env:\n") + fmt.Fprintf(sb, " GH_TOKEN: %s\n", step.token) + fmt.Fprintf(sb, " CASCADE_CLI_VERSION: %s\n", step.version) + sb.WriteString(" run: |\n") + sb.WriteString(binaryStepIndent + "set -euo pipefail\n") + sb.WriteString(binaryStepIndent + "CASCADE_WORK=\"$(mktemp -d)\"\n") + // Write install.sh verbatim via a quoted heredoc so nothing in it is expanded + // or re-interpreted; the script carries no ${{ }} and no line equal to the + // terminator, so it round-trips byte-for-byte. + sb.WriteString(binaryStepIndent + "cat > \"$CASCADE_WORK/install.sh\" <<'CASCADE_INSTALL_SH_EOF'\n") + writeIndentedScriptLines(sb, binaryStepIndent, setupCLIInstallScript) + sb.WriteString(binaryStepIndent + "CASCADE_INSTALL_SH_EOF\n") + sb.WriteString(binaryStepIndent + "export CASCADE_INSTALL_SCRIPT=\"$CASCADE_WORK/install.sh\"\n") + writeIndentedScriptLines(sb, binaryStepIndent, stripShebang(setupCLIBinaryPreamble)) +} + +// writeIndentedScriptLines emits script into a YAML literal block: every +// non-empty line is prefixed with indent (so it stays inside the block scalar), +// and empty lines are emitted bare so no trailing whitespace lands in the file. +// A single trailing newline on script is dropped to avoid an extra blank line. +func writeIndentedScriptLines(sb *strings.Builder, indent, script string) { + for _, line := range strings.Split(strings.TrimRight(script, "\n"), "\n") { + if strings.TrimRight(line, " \t") == "" { + sb.WriteByte('\n') + continue + } + sb.WriteString(indent) + sb.WriteString(line) + sb.WriteByte('\n') + } +} + +// stripShebang drops a leading "#!" line so the preamble reads as a plain +// script fragment when inlined into a run: block (where a shebang would only be +// a comment). The standalone file keeps its shebang for shellcheck. +func stripShebang(s string) string { + if strings.HasPrefix(s, "#!") { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[i+1:] + } + } + return s +} diff --git a/internal/generate/setup_cli_binary_preamble.sh b/internal/generate/setup_cli_binary_preamble.sh new file mode 100644 index 00000000..97a5dd90 --- /dev/null +++ b/internal/generate/setup_cli_binary_preamble.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Binary-mode preamble for the cascade setup-cli install. The generator emits +# this inline (via --cli-install=binary) instead of the setup-cli composite +# action, so a downstream repo installs the CLI with no third-party `uses:` step +# and needs no organization Actions allowlist entry for a third-party action. +# +# It resolves the requested version, detects the runner OS/arch, installs a +# checksum-verified cosign by direct binary download (not the cosign-installer +# action, so the authenticity tooling itself stays under first-party control and +# is gated the same way the release archive is), then hands off to install.sh. +# install.sh owns the mandatory sha256 gate and the keyless cosign verification +# of checksums.txt, and is shared byte-for-byte with the composite action, so the +# verify contract has exactly one source and cannot drift between the two modes. +# +# Everything the caller controls arrives through the environment, never spliced +# into this script text: +# GH_TOKEN token gh uses for release and cosign downloads +# CASCADE_CLI_VERSION requested version ("latest" or a concrete tag) +# CASCADE_INSTALL_SCRIPT path to the install.sh the caller wrote to disk +set -euo pipefail + +# cosign is pinned to an immutable release and gated against its published +# checksum, so a compromised or swapped cosign binary is rejected before it can +# run. Update both the version and the matching per-arch checksums together. +COSIGN_VERSION="v2.4.3" +COSIGN_SHA256_AMD64="caaad125acef1cb81d58dcdc454a1e429d09a750d1e9e2b3ed1aed8964454708" +COSIGN_SHA256_ARM64="bd0f9763bca54de88699c3656ade2f39c9a1c7a2916ff35601caf23a79be0629" + +VERSION="${CASCADE_CLI_VERSION:-latest}" + +# Resolve "latest" to the concrete release tag, matching the composite action and +# the pin-reconcile resolvers: pre-releases and drafts are excluded so a default +# caller never installs an rc or dryrun build. +if [ "$VERSION" = "latest" ]; then + TAG="$(gh release list -R stablekernel/cascade -L 1 --exclude-pre-releases --exclude-drafts --json tagName -q '.[0].tagName')" +else + TAG="$VERSION" +fi +if [ -z "${TAG:-}" ]; then + echo "::error::could not resolve a cascade release tag from version '$VERSION'." + exit 1 +fi + +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64 | arm64) ARCH="arm64" ;; +esac +ARCHIVE_PATTERN="cascade_*_${OS}_${ARCH}.tar.gz" + +# install_cosign fetches cosign by direct download and verifies its sha256 +# against the pinned value before installing it, so binary mode pulls in no +# third-party action and never runs an unverified cosign. A checksum mismatch +# aborts (set -e on the -c check). Only linux amd64/arm64 (the GitHub-hosted +# runner arches) carry a pinned checksum; any other target, or a failed +# download, skips the cosign install and lets install.sh fall back to the +# sha256-only gate with a loud warning, exactly as it does when a runner ships +# without cosign. +install_cosign() { + local expected="" dir asset + case "$ARCH" in + amd64) expected="$COSIGN_SHA256_AMD64" ;; + arm64) expected="$COSIGN_SHA256_ARM64" ;; + esac + if [ "$OS" != "linux" ] || [ -z "$expected" ]; then + echo "::warning::no pinned cosign checksum for ${OS}/${ARCH}; skipping cosign install (install.sh will fall back to sha256-only verification with a warning)." + return 0 + fi + if command -v cosign >/dev/null 2>&1; then + return 0 + fi + dir="$(mktemp -d)" + asset="cosign-linux-${ARCH}" + echo "Installing cosign ${COSIGN_VERSION} (${asset})..." + if ! curl -fsSL -o "$dir/cosign" "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${asset}"; then + echo "::warning::cosign download failed; skipping cosign install (install.sh will fall back to sha256-only verification with a warning)." + rm -rf "$dir" + return 0 + fi + echo "${expected} ${dir}/cosign" >"$dir/cosign.sha256" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "$dir/cosign.sha256" + else + shasum -a 256 -c "$dir/cosign.sha256" + fi + chmod +x "$dir/cosign" + # Plain install (no sudo), matching install.sh: /usr/local/bin is writable by + # the runner user on GitHub-hosted runners. + install -m 0755 "$dir/cosign" /usr/local/bin/cosign + rm -rf "$dir" +} +install_cosign + +INSTALL_SCRIPT="${CASCADE_INSTALL_SCRIPT:?CASCADE_INSTALL_SCRIPT must point at the install.sh written by the caller}" +TAG="$TAG" ARCHIVE_PATTERN="$ARCHIVE_PATTERN" bash "$INSTALL_SCRIPT" diff --git a/internal/generate/setup_cli_binary_test.go b/internal/generate/setup_cli_binary_test.go new file mode 100644 index 00000000..2cf22a09 --- /dev/null +++ b/internal/generate/setup_cli_binary_test.go @@ -0,0 +1,208 @@ +package generate + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" +) + +// TestSetupCLIInstallScript_MatchesActionCopy guards the one-source-of-truth +// invariant: the install.sh embedded into this package (and emitted inline in +// binary mode) must be byte-for-byte identical to the composite action's +// install.sh. go:embed cannot reach across the package boundary into the +// dot-directory action tree, so the script is copied here; this gate ensures the +// copy never drifts from the canonical script the setupcli hermetic tests run. +func TestSetupCLIInstallScript_MatchesActionCopy(t *testing.T) { + t.Parallel() + _, file, _, ok := runtime.Caller(0) + require.True(t, ok, "runtime.Caller failed") + root := filepath.Join(filepath.Dir(file), "..", "..") + canonical := filepath.Join(root, ".github", "actions", "setup-cli", "install.sh") + want, err := os.ReadFile(canonical) + require.NoError(t, err, "canonical install.sh missing") + require.Equal(t, string(want), setupCLIInstallScript, + "embedded setup_cli_install.sh drifted from .github/actions/setup-cli/install.sh; "+ + "copy the action's install.sh over internal/generate/setup_cli_install.sh") +} + +// TestParseCLIInstallMode covers the flag parser: the default and explicit +// action, binary, and a loud rejection of anything else. +func TestParseCLIInstallMode(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want cliInstallMode + wantErr bool + }{ + {"", cliInstallModeAction, false}, + {"action", cliInstallModeAction, false}, + {"binary", cliInstallModeBinary, false}, + {"Action", cliInstallModeAction, true}, + {"bin", cliInstallModeAction, true}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + got, err := parseCLIInstallMode(tc.in) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestWriteSetupCLIStep_ActionModeUnchanged pins the default action-mode output +// byte-for-byte, so the binary-mode branch can never perturb the emission every +// existing golden depends on. It covers the two field orderings and the if: +// guard the call sites use. +func TestWriteSetupCLIStep_ActionModeUnchanged(t *testing.T) { + t.Parallel() + + tokenFirst := func() string { + var sb strings.Builder + writeSetupCLIStep(&sb, setupCLIStep{ + ref: "v1.2.3", + version: "latest", + token: "${{ secrets.RELEASE_TOKEN }}", + tokenBeforeVersion: true, + }) + return sb.String() + }() + require.Equal(t, + " - name: Setup CLI\n"+ + " uses: stablekernel/cascade/.github/actions/setup-cli@v1.2.3\n"+ + " with:\n"+ + " token: ${{ secrets.RELEASE_TOKEN }}\n"+ + " version: latest\n", + tokenFirst) + + versionFirstWithIf := func() string { + var sb strings.Builder + writeSetupCLIStep(&sb, setupCLIStep{ + ref: "v1.2.3", + version: "latest", + token: "${{ github.token }}", + ifExpr: "steps.resolve.outputs.relevant == 'true'", + }) + return sb.String() + }() + require.Equal(t, + " - name: Setup CLI\n"+ + " if: steps.resolve.outputs.relevant == 'true'\n"+ + " uses: stablekernel/cascade/.github/actions/setup-cli@v1.2.3\n"+ + " with:\n"+ + " version: latest\n"+ + " token: ${{ github.token }}\n", + versionFirstWithIf) +} + +// TestWriteSetupCLIStepBinary_VerifyContract asserts the binary-mode emission +// carries every guarantee the composite action provides while using no +// third-party action. +func TestWriteSetupCLIStepBinary_VerifyContract(t *testing.T) { + t.Parallel() + + var sb strings.Builder + writeSetupCLIStep(&sb, setupCLIStep{ + ref: "v1.2.3", + version: "latest", + token: "${{ secrets.RELEASE_TOKEN }}", + tokenBeforeVersion: true, + ifExpr: "steps.resolve.outputs.relevant == 'true'", + installMode: cliInstallModeBinary, + }) + out := sb.String() + + // No third-party action of any kind: not the setup-cli composite action, not + // the cosign installer. + require.NotContains(t, out, "uses: stablekernel/cascade/.github/actions/setup-cli@", + "binary mode must not reference the setup-cli composite action") + require.NotContains(t, out, "sigstore/cosign-installer", + "binary mode must install cosign by direct download, not the installer action") + require.NotContains(t, out, "\n uses:", + "binary mode must emit no uses: step at all") + + // Token and version flow through env, never spliced into the run: script, so + // no ${{ }} appears inside the script body (which real GitHub would evaluate + // at parse time). + require.Contains(t, out, " env:\n") + require.Contains(t, out, " GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}\n") + require.Contains(t, out, " CASCADE_CLI_VERSION: latest\n") + + // The step-level if: guard is honored. + require.Contains(t, out, " if: steps.resolve.outputs.relevant == 'true'\n") + + // Mandatory sha256 integrity gate (from the shared install.sh). + require.Contains(t, out, "checksums.txt") + require.Contains(t, out, "sha256sum -c") + + // Keyless cosign verification with the exact identity and issuer the action + // uses. These are load-bearing: a wrong regexp or issuer would verify against + // the wrong signer. + require.Contains(t, out, "cosign verify-blob") + require.Contains(t, out, "--certificate-identity-regexp='^https://github.com/stablekernel/cascade/'") + require.Contains(t, out, "--certificate-oidc-issuer=https://token.actions.githubusercontent.com") + + // cosign installed by pinned, checksum-verified direct download. + require.Contains(t, out, "https://github.com/sigstore/cosign/releases/download/") + require.Contains(t, out, "COSIGN_VERSION=") + + // The loud sha256-only fallback semantics survive (never a silent downgrade). + require.Contains(t, out, "::warning::") + + // The run: body carries no ${{ so GitHub does not evaluate the script at parse. + body := out[strings.Index(out, " run: |\n"):] + require.NotContains(t, body, "${{", + "the binary-mode run: script must contain no ${{ }} expression") +} + +// TestGenerateBinaryMode_ActionlintCleanAndThirdPartyFree renders a full +// orchestrate workflow in binary mode and asserts (a) actionlint accepts it, +// proving real GitHub would parse the inline install step (a literal ${{ }} in a +// run: script would 422 at parse), and (b) it references no third-party action. +// +// Only the emitted YAML is exercised here. The download-and-verify half is not +// run: act (the e2e runner) and this unit test cannot fetch a real GitHub +// release or run cosign, so faking it would prove nothing. The executed proof of +// the verify gates (good checksum, tampered checksum, missing entry, rejected +// signature) lives in internal/setupcli against the same install.sh this mode +// embeds byte-for-byte, guarded by TestSetupCLIInstallScript_MatchesActionCopy. +func TestGenerateBinaryMode_ActionlintCleanAndThirdPartyFree(t *testing.T) { + actionlint := locateActionlint(t) + dir, wfDir := stageActionlintProject(t) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: config.EnvNames("staging", "production"), + Builds: []config.BuildConfig{ + {Name: "app", Workflow: "build.yaml", Triggers: []string{"src/**"}}, + }, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: "deploy.yaml", DependsOn: []string{"app"}}, + }, + } + + g := NewGenerator(cfg, dir) + g.setInstallMode(cliInstallModeBinary) + content, err := g.Generate() + require.NoError(t, err) + + require.NotContains(t, content, "uses: stablekernel/cascade/.github/actions/setup-cli@") + require.NotContains(t, content, "sigstore/cosign-installer") + require.Contains(t, content, "cosign verify-blob") + + path := filepath.Join(wfDir, "orchestrate.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + out, runErr := runActionlint(t, actionlint, path) + require.NoErrorf(t, runErr, "actionlint rejected the binary-mode orchestrate workflow:\n%s", out) +} diff --git a/internal/generate/setup_cli_install.sh b/internal/generate/setup_cli_install.sh new file mode 100755 index 00000000..d044d7ed --- /dev/null +++ b/internal/generate/setup_cli_install.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Downloads, verifies, and installs the cascade CLI release archive. Called by +# the setup-cli composite action (action.yaml) and exercised hermetically by +# internal/setupcli against fixture releases and a stubbed gh, so keep the +# interface stable: +# +# GH_TOKEN token gh uses for the release downloads +# TAG concrete release tag (already resolved, never "latest") +# ARCHIVE_PATTERN OS/arch archive glob, e.g. cascade_*_linux_amd64.tar.gz +# CASCADE_INSTALL_DIR install destination, defaults to /usr/local/bin +set -euo pipefail + +INSTALL_DIR="${CASCADE_INSTALL_DIR:-/usr/local/bin}" + +# GitHub's API and release CDN occasionally answer a download for a release +# that exists with a spurious error: a 5xx, an HTML error page, or a "release +# not found" that succeeds on the very next call. An unguarded download turns +# one such blip into a failed install, so every gh call below runs through a +# small retry budget with exponential backoff. +# +# Every failure is retried, rather than only those matching a classifier. gh's +# error text does not reliably separate a transient fault from a permanent one +# (the incident that motivated this reported a missing release for one that was +# published), and a wrong classifier fails in the more damaging direction. The +# budget is the safeguard instead: three attempts and roughly 6s of backoff +# means a genuinely missing release (a deleted tag, a typo'd version) still +# fails loudly within seconds rather than hanging, so a dangling pin stays as +# easy to spot as it was before. +GH_MAX_ATTEMPTS="${CASCADE_GH_MAX_ATTEMPTS:-3}" +GH_RETRY_BASE_SLEEP="${CASCADE_GH_RETRY_BASE_SLEEP:-2}" + +# Both knobs are validated before anything runs. A value that is not an integer +# makes the comparisons below error rather than compare, and an errored test is +# falsy, so the loop would never reach its stop condition: an unbounded retry +# with a doubling sleep, the exact failure the budget exists to prevent. A bad +# value is rejected loudly rather than defaulted to, because these are only ever +# set deliberately: silently substituting the default would leave someone who +# asked for five attempts getting three and never learning why. +case "$GH_MAX_ATTEMPTS" in + '' | *[!0-9]*) + echo "::error::CASCADE_GH_MAX_ATTEMPTS must be a positive integer, got '$GH_MAX_ATTEMPTS'." + exit 1 + ;; +esac +if [ "$GH_MAX_ATTEMPTS" -lt 1 ]; then + echo "::error::CASCADE_GH_MAX_ATTEMPTS must be a positive integer, got '$GH_MAX_ATTEMPTS'." + exit 1 +fi +case "$GH_RETRY_BASE_SLEEP" in + '' | *[!0-9]*) + echo "::error::CASCADE_GH_RETRY_BASE_SLEEP must be a non-negative integer, got '$GH_RETRY_BASE_SLEEP'." + exit 1 + ;; +esac + +gh_retry() { + local attempt=1 + local sleep_for="$GH_RETRY_BASE_SLEEP" + local rc=0 + while :; do + # rc is captured in the else branch on purpose: an if whose condition fails + # and has no else exits 0, so reading $? after fi would lose gh's code. + if gh "$@"; then + return 0 + else + rc=$? + fi + if [ "$attempt" -ge "$GH_MAX_ATTEMPTS" ]; then + echo "gh $1 $2 failed after $attempt attempts (exit $rc)." + return "$rc" + fi + echo "gh $1 $2 failed (attempt $attempt/$GH_MAX_ATTEMPTS); retrying in ${sleep_for}s..." + if [ "$sleep_for" -gt 0 ]; then + sleep "$sleep_for" + fi + attempt=$((attempt + 1)) + sleep_for=$((sleep_for * 2)) + done +} + +echo "Downloading archive matching $ARCHIVE_PATTERN from release $TAG..." +workdir=$(mktemp -d) +gh_retry release download "$TAG" \ + -R stablekernel/cascade \ + -p "$ARCHIVE_PATTERN" \ + -D "$workdir" + +# Integrity gate: every release publishes checksums.txt, and nothing is +# installed until the downloaded archive's sha256 matches it. A release +# without checksums.txt, an archive with no checksum entry, or a hash +# mismatch all abort the install. +gh_retry release download "$TAG" \ + -R stablekernel/cascade \ + -p "checksums.txt" \ + -D "$workdir" + +cd "$workdir" +shopt -s nullglob +archives=(cascade_*.tar.gz) +if [ "${#archives[@]}" -ne 1 ]; then + echo "::error::Expected exactly one downloaded archive for $ARCHIVE_PATTERN, found ${#archives[@]}; refusing to install." + exit 1 +fi +ARCHIVE="${archives[0]}" +if ! awk -v f="$ARCHIVE" '$2 == f { print; ok = 1 } END { exit ok ? 0 : 1 }' checksums.txt > archive.sha256; then + echo "::error::No checksum entry for $ARCHIVE in checksums.txt of release $TAG; refusing to install." + exit 1 +fi +if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c archive.sha256 +else + shasum -a 256 -c archive.sha256 +fi + +# Authenticity gate: the release pipeline cosign-signs checksums.txt +# keylessly (Sigstore bundle format, cosign v3). Verify the signature +# whenever the bundle and a cosign binary are available; a failed +# verification always aborts. If either is unavailable (an old release +# without a bundle, or a runner without cosign), fall back to the checksum +# gate above with a loud warning, never silently. +# Manual equivalent: docs/release-verification.md. +# The bundle fetch retries too: a blip here would otherwise downgrade a signed +# release to the sha256-only fallback silently, which is the weaker gate. The +# cost is that a release genuinely published without a bundle now spends the +# retry budget before warning, which is seconds on a path that already warns. +if gh_retry release download "$TAG" -R stablekernel/cascade -p "checksums.txt.bundle" -D .; then + if command -v cosign >/dev/null 2>&1; then + # cosign v2 needs --new-bundle-format to read the Sigstore bundle + # format that the release pipeline (cosign v3) emits; probe the flag + # so both major versions verify correctly. + NEW_BUNDLE_FLAG="" + COSIGN_HELP="$(cosign verify-blob --help 2>&1 || true)" + case "$COSIGN_HELP" in + *--new-bundle-format*) NEW_BUNDLE_FLAG="--new-bundle-format=true" ;; + esac + cosign verify-blob \ + --bundle=checksums.txt.bundle \ + --certificate-identity-regexp='^https://github.com/stablekernel/cascade/' \ + --certificate-oidc-issuer=https://token.actions.githubusercontent.com \ + ${NEW_BUNDLE_FLAG:+"$NEW_BUNDLE_FLAG"} \ + checksums.txt + echo "cosign signature on checksums.txt verified." + else + echo "::warning::cosign not found on this runner; proceeding on sha256 verification only (checksums.txt signature not verified)." + fi +else + echo "::warning::Could not download checksums.txt.bundle for release $TAG; proceeding on sha256 verification only (checksums.txt signature not verified)." +fi + +tar -xzf "$ARCHIVE" +install -m 0755 cascade "$INSTALL_DIR/cascade" +cd / +rm -rf "$workdir" diff --git a/internal/generate/validate_check.go b/internal/generate/validate_check.go index 58d9837d..81615d44 100644 --- a/internal/generate/validate_check.go +++ b/internal/generate/validate_check.go @@ -15,6 +15,7 @@ import ( // not run the consumer's build/test CI, requests contents: read alone, and has // no dry-run or comment side effects. type ValidateCheckGenerator struct { + installModeHolder config *config.TrunkConfig baseDir string } @@ -98,9 +99,10 @@ func (g *ValidateCheckGenerator) writeJob(sb *strings.Builder) { // github.token is the built-in Actions token, sufficient to authenticate // gh release download against the public stablekernel/cascade repository. writeSetupCLIStep(sb, setupCLIStep{ - ref: g.getCLIRef(), - version: g.config.GetCLIVersion(), - token: "${{ github.token }}", + installMode: g.installMode, + ref: g.getCLIRef(), + version: g.config.GetCLIVersion(), + token: "${{ github.token }}", }) sb.WriteString(" - name: Validate Manifest\n")