diff --git a/README.md b/README.md index 8b524de..48fcbf1 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 4925543..14e4a0c 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 b839270..c574f98 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 a6b6253..4013f15 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 4a3dd90..05a94f8 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 e643715..bc3bbbc 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 9696a27..bd3f60b 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 6cb8aa2..14f3fc9 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 12e7dac..8ec1df2 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 27e5311..cb20d2f 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 6ed2127..b6330bd 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 d3c9688..9e1e737 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 577c046..abebb4b 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 ea77978..513367e 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 9cbddf5..48a7e84 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 0000000..97a5dd9 --- /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 0000000..2cf22a0 --- /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 0000000..d044d7e --- /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 58d9837..81615d4 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")