diff --git a/.github/manifest.yaml b/.github/manifest.yaml index 78f6fb4..55fcdc9 100644 --- a/.github/manifest.yaml +++ b/.github/manifest.yaml @@ -11,6 +11,7 @@ ci: - go.mod - go.sum cli_version: v0.1.0 + cli_version_sha: 9dc69a1f66753a3865c38c34eca5a931f677c803 pin_mode: sha manifest_file: .github/manifest.yaml manifest_key: ci diff --git a/.github/workflows/fleet-e2e.yaml b/.github/workflows/fleet-e2e.yaml index aa658ef..2a1a032 100644 --- a/.github/workflows/fleet-e2e.yaml +++ b/.github/workflows/fleet-e2e.yaml @@ -324,6 +324,23 @@ jobs: RC_BARE="${RC_VERSION#v}" echo "RC_BARE=$RC_BARE" >> "$GITHUB_ENV" + # Peel the rc tag to its commit SHA so the repin can SHA-pin each + # example repo's setup-cli self-action (cli_version_sha). cascade's + # release tags are annotated, so refs/tags/ is a tag-object SHA, + # not a commit; ^{} dereferences to the underlying commit. A + # lightweight tag has no peeled ref, so fall back to the bare ref, + # which is already the commit. + RC_SHA=$(git ls-remote "https://github.com/${REPO}" "refs/tags/${RC_VERSION}^{}" | awk '{print $1}') + if [ -z "$RC_SHA" ]; then + RC_SHA=$(git ls-remote "https://github.com/${REPO}" "refs/tags/${RC_VERSION}" | awk '{print $1}') + fi + if ! printf '%s' "$RC_SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error::Could not resolve a commit SHA for tag ${RC_VERSION} (got '${RC_SHA}')" + exit 1 + fi + echo "Resolved ${RC_VERSION} to commit ${RC_SHA}" + echo "RC_SHA=$RC_SHA" >> "$GITHUB_ENV" + TMPDIR=$(mktemp -d) echo "Downloading $RC_VERSION linux/amd64 archive from $REPO" gh release download "$RC_VERSION" \ @@ -370,6 +387,16 @@ jobs: # 1. Point the manifest cli_version at the rc. sed -i -E "s|^([[:space:]]*cli_version:[[:space:]]*).*$|\1${RC_VERSION}|" "$manifest" + # 1b. Pair cli_version_sha with the rc tag's peeled commit so the + # regenerated setup-cli self-action ref is SHA-pinned (under + # pin_mode: sha). Update it in place when present, else insert a + # sibling line right after cli_version preserving its indent. + if grep -qE "^[[:space:]]*cli_version_sha:" "$manifest"; then + sed -i -E "s|^([[:space:]]*cli_version_sha:[[:space:]]*).*$|\1${RC_SHA}|" "$manifest" + else + sed -i -E "s|^([[:space:]]*)cli_version:([[:space:]]*).*$|&\n\1cli_version_sha:\2${RC_SHA}|" "$manifest" + fi + # 2. Replace any other in-repo rc-version refs (e.g. an explicit # setup-cli@v..-rc.. pin a suite hand-wrote) with the rc. Scope # to tracked text files; the regen below rewrites generated diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index 5466455..b52d935 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -36,7 +36,7 @@ jobs: with: fetch-depth: 0 - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} version: v0.1.0 @@ -109,7 +109,7 @@ jobs: echo "_No outputs produced_" >> "$GITHUB_STEP_SUMMARY" fi - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} version: v0.1.0 diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index c750e84..d2f858e 100644 --- a/.github/workflows/promote.yaml +++ b/.github/workflows/promote.yaml @@ -93,7 +93,7 @@ jobs: with: fetch-depth: 0 - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} version: v0.1.0 @@ -128,7 +128,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} version: v0.1.0 @@ -155,7 +155,7 @@ jobs: with: fetch-depth: 0 - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} version: v0.1.0 diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 0594ada..623c540 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -84,6 +84,11 @@ "type": "string", "description": "cascade CLI version pinned into generated workflows (for example \"v1.0.0\"). Defaults to the pinned release tag when unset or \"latest\"." }, + "cli_version_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "description": "40-character lowercase-hex commit SHA that cli_version resolves to. When set and pin_mode is sha, generated setup-cli self-action refs are pinned to this commit with cli_version carried as a trailing comment. Written by the version-bump automation, which peels the annotated cli_version tag to its commit." + }, "tag_prefix": { "type": "string", "description": "Version tag prefix (default: \"v\")." diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index ae21f34..a67e7fa 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -83,6 +83,7 @@ ci: | `trunk_branch` | string | Yes | - | Main branch (e.g., `master`, `main`) | | `environments` | list | No | - | Promotion chain. Omit for no-env library/CLI projects. | | `cli_version` | string | No | latest | CLI version: `latest`, `beta`, or specific version (e.g., `v2.0.4`) | +| `cli_version_sha` | string | No | - | 40-hex commit SHA that `cli_version` resolves to. With `pin_mode: sha`, the generated setup-cli ref is pinned to this commit. See [cli_version_sha](#cli_version_sha). | | `triggers` | list | No | - | Global path patterns that activate orchestration | | `tag_prefix` | string | No | `v` | Version tag prefix | | `release_token` | string | No | `state_token` if set, else `${{ secrets.GITHUB_TOKEN }}` | Token expression for release API calls and the rc tag; inherits `state_token` when unset so the rc-to-release chain has a trigger-capable token | @@ -111,6 +112,18 @@ Controls which CLI version the generated workflows install via setup-cli: Pin to a specific version for reproducibility. Use `beta` for early access. +### cli_version_sha + +When `pin_mode: sha` is set, pair `cli_version` with `cli_version_sha`, the 40-character lowercase-hex commit SHA that the `cli_version` tag resolves to. The generated setup-cli ref is then pinned to that immutable commit, with `cli_version` carried as a trailing comment: + +```yaml +uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 +``` + +The `with: version:` input the action reads to select the release asset stays the human-readable tag, so only the action source is pinned to a commit. + +This closes the supply-chain gap where the cascade self-action was referenced by a mutable tag while third-party actions were already SHA-pinned. The field is optional and only takes effect under `pin_mode: sha`; leave it unset (or use the default `pin_mode: tag`) to keep the tag-based ref. Set `cli_version_sha` alongside `cli_version` whenever you bump the pinned version. Because cascade release tags are annotated, resolve the underlying commit (not the tag object) with `git ls-remote https://github.com/stablekernel/cascade 'refs/tags/^{}'`. + ### Token authentication Two seams call GitHub on cascade's behalf: `release_token` for release API calls and `state_token` for writing manifest state back to the trunk branch. Both default to `${{ secrets.GITHUB_TOKEN }}`, which is enough for a single-repo project whose trunk is unprotected. When the default token cannot do the job, supply your own token through one of two paths: a static secret (PAT) or a GitHub App. diff --git a/internal/config/schema_v1_test.go b/internal/config/schema_v1_test.go index 7449d08..03dcfe2 100644 --- a/internal/config/schema_v1_test.go +++ b/internal/config/schema_v1_test.go @@ -610,6 +610,24 @@ func TestValidateConfigLevelRules(t *testing.T) { t.Fatalf("expected pin_mode rejection, got %v", errs) } }) + t.Run("cli_version_sha non-hex rejected", func(t *testing.T) { + cfg := parseInline(t, "cli_version_sha: not-a-sha\n") + if errs := Validate(cfg); !hasErrContaining(errs, "cli_version_sha must be a 40-character lowercase hex commit SHA") { + t.Fatalf("expected cli_version_sha rejection, got %v", errs) + } + }) + t.Run("cli_version_sha short hex rejected", func(t *testing.T) { + cfg := parseInline(t, "cli_version_sha: 9dc69a1f\n") + if errs := Validate(cfg); !hasErrContaining(errs, "cli_version_sha must be a 40-character lowercase hex commit SHA") { + t.Fatalf("expected short cli_version_sha rejection, got %v", errs) + } + }) + t.Run("cli_version_sha valid 40-hex accepted", func(t *testing.T) { + cfg := parseInline(t, "cli_version_sha: 9dc69a1f66753a3865c38c34eca5a931f677c803\n") + if errs := Validate(cfg); hasErrContaining(errs, "cli_version_sha") { + t.Fatalf("expected valid cli_version_sha to pass, got %v", errs) + } + }) t.Run("reserved dispatch input shadow rejected", func(t *testing.T) { cfg := parseInline(t, ` dispatch_inputs: diff --git a/internal/config/types.go b/internal/config/types.go index e81cf6e..387ab1b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -125,6 +125,12 @@ type TrunkConfig struct { ReleaseTrigger string `yaml:"release_trigger,omitempty" json:"release_trigger,omitempty"` Environments []string `yaml:"environments,omitempty" json:"environments,omitempty"` // Empty = no-environment setup (library/CLI projects) CLIVersion string `yaml:"cli_version,omitempty" json:"cli_version,omitempty"` // cascade CLI version (e.g., v1.0.0) + // CLIVersionSHA is the 40-hex commit SHA that cli_version resolves to. When + // set and pin_mode is sha, generated setup-cli self-action refs are pinned to + // this commit with cli_version carried as a trailing comment; otherwise the + // version tag is emitted (today's behavior). The bump automation peels the + // annotated cli_version tag to its commit and writes this. + CLIVersionSHA string `yaml:"cli_version_sha,omitempty" json:"cli_version_sha,omitempty"` TagPrefix string `yaml:"tag_prefix,omitempty" json:"tag_prefix,omitempty"` // Version tag prefix (default: "v") ReleaseToken string `yaml:"release_token,omitempty" json:"release_token,omitempty"` // GitHub secret name for release operations (default: "GITHUB_TOKEN") StateToken string `yaml:"state_token,omitempty" json:"state_token,omitempty"` // Token expression for writing manifest state to the trunk branch (default: "GITHUB_TOKEN") diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 3621b1d..0f40799 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -56,6 +56,9 @@ var validRolloutTypes = map[string]bool{ // ID and the ${{ }} dereferences that read its outputs. var jobIDSafeNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) +// commitSHARe matches a full 40-character lowercase-hex Git commit SHA. +var commitSHARe = regexp.MustCompile(`^[0-9a-f]{40}$`) + // validateJobIDSafeName rejects a name that would produce an invalid GitHub // Actions job ID or break the expression references derived from it. Names are // rejected (not sanitized) on purpose: sanitizing distinct names could collapse @@ -278,6 +281,13 @@ func validateConfigLevel(cfg *TrunkConfig) []string { errs = append(errs, "pin_mode must be one of: tag, sha") } + // cli_version_sha, when set, must be a 40-char lowercase-hex commit SHA so it + // can be SHA-pinned into the generated setup-cli refs without producing a + // broken, unresolvable ref. + if cfg.CLIVersionSHA != "" && !commitSHARe.MatchString(cfg.CLIVersionSHA) { + errs = append(errs, "cli_version_sha must be a 40-character lowercase hex commit SHA") + } + // release_trigger must be push or dispatch. if cfg.ReleaseTrigger != "" && cfg.ReleaseTrigger != ReleaseTriggerPush && cfg.ReleaseTrigger != ReleaseTriggerDispatch { errs = append(errs, "release_trigger must be one of: push, dispatch") diff --git a/internal/generate/action_pins.go b/internal/generate/action_pins.go index db4476d..fc59a1b 100644 --- a/internal/generate/action_pins.go +++ b/internal/generate/action_pins.go @@ -82,6 +82,30 @@ func actionRef(cfg *config.TrunkConfig, action string) string { return action + "@" + pin.tag } +// cliSetupRef returns the ref portion emitted after "setup-cli@" for cascade's +// own self-action. It returns a 40-hex commit SHA with a trailing "# " +// comment when pin_mode is sha and cli_version_sha is set, otherwise the +// cli_version tag (today's behavior). "beta" always opts into the master trunk. +// +// Unlike third-party actions (resolved by actionRef against a static pin table), +// the self-action SHA tracks cli_version, which moves every release, so it is +// sourced from the manifest (written by the bump automation) rather than a baked +// constant. Generation stays a pure offline function of the committed manifest. +// +// Precedence: beta (master) > (pin_mode sha AND cli_version_sha set) > tag. An +// empty cli_version_sha under pin_mode sha degrades gracefully to the tag, +// never a broken ref. +func cliSetupRef(cfg *config.TrunkConfig) string { + if cfg.CLIVersion == "beta" { + return "master" // Explicit opt-in escape hatch to trunk. + } + version := cfg.GetCLIVersion() + if cfg.GetPinMode() == config.PinModeSHA && cfg.CLIVersionSHA != "" { + return cfg.CLIVersionSHA + " # " + version + } + return version +} + // writeActionStep writes a "- uses: \n" line for a third-party // action, routing the ref through actionRef so the pin policy is applied. Pass // the leading indentation (spaces before "- "). diff --git a/internal/generate/action_pins_test.go b/internal/generate/action_pins_test.go index 8109aa0..ae448d1 100644 --- a/internal/generate/action_pins_test.go +++ b/internal/generate/action_pins_test.go @@ -99,6 +99,87 @@ func TestActionRef_NilConfigSafe(t *testing.T) { assert.Equal(t, actionCheckout+"@"+defaultActionPins[actionCheckout].tag, actionRef(nil, actionCheckout)) } +// TestCLISetupRef covers the self-action ref resolution: the version tag in tag +// mode (today's behavior), the 40-hex commit SHA with a trailing version comment +// when pin_mode is sha and cli_version_sha is set, graceful fallback to the tag +// when the SHA is absent, and the beta -> master escape hatch. +// +// There is no act+gitea e2e scenario for this feature. The e2e harness localizes +// cross-repo self-references (stablekernel/cascade/.github/actions/setup-cli@ +// -> ./.github/actions/setup-cli) before running act, so the pinned ref is erased +// before the workflow executes and no in-container assertion can observe it. Unit +// coverage here (cliSetupRef) plus the regenerated cascade orchestrate.yaml / +// promote.yaml showing @9dc69a1f... # v0.1.0 are the proof of correctness. +func TestCLISetupRef(t *testing.T) { + const sha = "9dc69a1f66753a3865c38c34eca5a931f677c803" + + tests := []struct { + name string + cfg *config.TrunkConfig + want string + }{ + { + name: "tag default when cli_version unset", + cfg: &config.TrunkConfig{}, + want: config.DefaultCLIVersion, + }, + { + name: "explicit tag in tag mode", + cfg: &config.TrunkConfig{CLIVersion: "v1.2.3", PinMode: config.PinModeTag}, + want: "v1.2.3", + }, + { + name: "sha mode with sha set emits sha and version comment", + cfg: &config.TrunkConfig{CLIVersion: "v1.2.3", PinMode: config.PinModeSHA, CLIVersionSHA: sha}, + want: sha + " # v1.2.3", + }, + { + name: "sha mode without sha falls back to tag", + cfg: &config.TrunkConfig{CLIVersion: "v1.2.3", PinMode: config.PinModeSHA}, + want: "v1.2.3", + }, + { + name: "tag mode ignores a set sha", + cfg: &config.TrunkConfig{CLIVersion: "v1.2.3", PinMode: config.PinModeTag, CLIVersionSHA: sha}, + want: "v1.2.3", + }, + { + name: "beta opts into master regardless of pin mode", + cfg: &config.TrunkConfig{CLIVersion: "beta", PinMode: config.PinModeSHA, CLIVersionSHA: sha}, + want: "master", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, cliSetupRef(tt.cfg)) + }) + } +} + +// TestPinPolicy_E2E_SelfActionSHAPinned asserts that when pin_mode is sha and +// cli_version_sha is set, every generated setup-cli self-action ref in the +// orchestrate output is pinned to the 40-hex commit with cli_version carried as a +// trailing comment, and the bare version-tag uses: ref no longer appears. This is +// the generator-level end-to-end proof; the act+gitea harness cannot observe this +// because it localizes the cross-repo self-ref before running act (see TestCLISetupRef +// for a full explanation). +func TestPinPolicy_E2E_SelfActionSHAPinned(t *testing.T) { + const sha = "9dc69a1f66753a3865c38c34eca5a931f677c803" + cfg, tmpDir := newPinE2EConfig(t) + cfg.CLIVersion = "v0.1.0" + cfg.CLIVersionSHA = sha + + out, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + const action = "stablekernel/cascade/.github/actions/setup-cli" + assert.Contains(t, out, action+"@"+sha+" # v0.1.0", + "self-action must be SHA-pinned with a version comment") + assert.NotContains(t, out, "uses: "+action+"@v0.1.0\n", + "the bare version tag must not remain as a uses: ref once SHA-pinned") +} + // newPinE2EConfig builds a manifest exercising the orchestrate, build, deploy, // and release emission paths so the generated workflow contains every // third-party uses: line the pin policy governs. diff --git a/internal/generate/drift_check.go b/internal/generate/drift_check.go index ce5cbbd..4e5508f 100644 --- a/internal/generate/drift_check.go +++ b/internal/generate/drift_check.go @@ -65,10 +65,7 @@ func (g *DriftCheckGenerator) commentEnabled() bool { // (cli_version unset or "latest") resolves to an immutable release tag, so // consumers never run an unpinned mutable ref; "beta" opts in to "master". func (g *DriftCheckGenerator) getCLIRef() string { - if g.config.CLIVersion == "beta" { - return "master" - } - return g.config.GetCLIVersion() + return cliSetupRef(g.config) } // Generate creates the pull_request drift-check workflow content. diff --git a/internal/generate/external.go b/internal/generate/external.go index 47eb3d3..7a419d7 100644 --- a/internal/generate/external.go +++ b/internal/generate/external.go @@ -26,10 +26,7 @@ func NewExternalUpdateGenerator(cfg *config.TrunkConfig, baseDir string) *Extern // immutable release tag, so consumers never run an unpinned mutable ref. // "beta" is the explicit opt-in escape hatch to the "master" branch. func (g *ExternalUpdateGenerator) getCLIRef() string { - if g.config.CLIVersion == "beta" { - return "master" // Explicit opt-in escape hatch to trunk. - } - return g.config.GetCLIVersion() + return cliSetupRef(g.config) } // getReleaseTokenRef returns the token expression for release operations. diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 4c09e3a..cdbdfde 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -191,18 +191,13 @@ func (g *Generator) anyAutoCommits() bool { return false } -// getCLIRef returns the Git ref to use for the cascade self-action. The default -// (cli_version unset or "latest") resolves to config.DefaultCLIVersion, an -// immutable release tag, so consumers never run an unpinned mutable ref. -// Supported values: -// - unset / "latest" → config.DefaultCLIVersion (immutable, pinned default) -// - "beta" → "master" branch (explicit opt-in, bleeding edge, may be unstable) -// - "vX.Y.Z" → that specific version tag +// getCLIRef returns the ref emitted after "setup-cli@" for the cascade +// self-action. It delegates to cliSetupRef, the single policy seam shared by +// every generator, which resolves cli_version to an immutable release tag (or a +// 40-hex commit SHA with a version comment under pin_mode: sha) so consumers +// never run an unpinned mutable ref. func (g *Generator) getCLIRef() string { - if g.config.CLIVersion == "beta" { - return "master" // Explicit opt-in escape hatch to trunk. - } - return g.config.GetCLIVersion() + return cliSetupRef(g.config) } // getReleaseTokenRef returns the token expression for release operations. diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index 6d06ebb..29c9d8e 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -83,10 +83,7 @@ func (g *HotfixGenerator) targetEnvs() []string { // unset or "latest") resolves to config.DefaultCLIVersion, an immutable release // tag; "beta" is the explicit opt-in escape hatch to the "master" branch. func (g *HotfixGenerator) getCLIRef() string { - if g.config.CLIVersion == "beta" { - return "master" // Explicit opt-in escape hatch to trunk. - } - return g.config.GetCLIVersion() + return cliSetupRef(g.config) } // getManifestFilePath returns the repo-relative manifest path for use in the diff --git a/internal/generate/merge_queue.go b/internal/generate/merge_queue.go index 10b090a..3904f66 100644 --- a/internal/generate/merge_queue.go +++ b/internal/generate/merge_queue.go @@ -42,10 +42,7 @@ func (g *MergeQueueGenerator) Enabled() bool { // unset or "latest") resolves to config.DefaultCLIVersion, an immutable release // tag; "beta" is the explicit opt-in escape hatch to the "master" branch. func (g *MergeQueueGenerator) getCLIRef() string { - if g.config.CLIVersion == "beta" { - return "master" // Explicit opt-in escape hatch to trunk. - } - return g.config.GetCLIVersion() + return cliSetupRef(g.config) } // getManifestFilePath returns the repo-relative manifest path for use in the diff --git a/internal/generate/pr_preview.go b/internal/generate/pr_preview.go index ed24f6b..bd3f813 100644 --- a/internal/generate/pr_preview.go +++ b/internal/generate/pr_preview.go @@ -39,10 +39,7 @@ func NewPRPreviewGenerator(cfg *config.TrunkConfig, baseDir string) *PRPreviewGe // immutable release tag, so consumers never run an unpinned mutable ref. // "beta" is the explicit opt-in escape hatch to the "master" branch. func (g *PRPreviewGenerator) getCLIRef() string { - if g.config.CLIVersion == "beta" { - return "master" // Explicit opt-in escape hatch to trunk. - } - return g.config.GetCLIVersion() + return cliSetupRef(g.config) } // commentEnabled reports whether the preview should also post a PR comment. diff --git a/internal/generate/promote.go b/internal/generate/promote.go index fc60188..1882c7e 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -47,10 +47,7 @@ func (g *PromoteGenerator) SetState(state map[string]*config.EnvState) { // - "beta" → "master" branch (explicit opt-in, bleeding edge, may be unstable) // - "vX.Y.Z" → that specific version tag func (g *PromoteGenerator) getCLIRef() string { - if g.config.CLIVersion == "beta" { - return "master" // Explicit opt-in escape hatch to trunk. - } - return g.config.GetCLIVersion() + return cliSetupRef(g.config) } // getReleaseTokenRef returns the token expression for release operations. diff --git a/internal/generate/release.go b/internal/generate/release.go index 0041f49..dc8f042 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -30,10 +30,7 @@ func NewReleaseGenerator(cfg *config.TrunkConfig, baseDir string) *ReleaseGenera // - "beta" → "master" branch (explicit opt-in, bleeding edge, may be unstable) // - "vX.Y.Z" → that specific version tag func (g *ReleaseGenerator) getCLIRef() string { - if g.config.CLIVersion == "beta" { - return "master" // Explicit opt-in escape hatch to trunk. - } - return g.config.GetCLIVersion() + return cliSetupRef(g.config) } // getReleaseTokenRef returns the token expression for release operations. diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index 766430d..095f613 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -67,10 +67,7 @@ func (g *RollbackGenerator) paramRead(name string) string { // escape hatch to the "master" branch; everything else resolves through // GetCLIVersion (which pins "" / "latest" to the immutable default). func (g *RollbackGenerator) getCLIRef() string { - if g.config.CLIVersion == "beta" { - return "master" // Explicit opt-in escape hatch to trunk. - } - return g.config.GetCLIVersion() + return cliSetupRef(g.config) } // getReleaseTokenRef returns the token expression for deploy/release operations. diff --git a/internal/generate/validate_check.go b/internal/generate/validate_check.go index 4f0b389..1833006 100644 --- a/internal/generate/validate_check.go +++ b/internal/generate/validate_check.go @@ -38,10 +38,7 @@ func (g *ValidateCheckGenerator) Enabled() bool { // unset or "latest") resolves to config.DefaultCLIVersion, an immutable release // tag; "beta" is the explicit opt-in escape hatch to the "master" branch. func (g *ValidateCheckGenerator) getCLIRef() string { - if g.config.CLIVersion == "beta" { - return "master" // Explicit opt-in escape hatch to trunk. - } - return g.config.GetCLIVersion() + return cliSetupRef(g.config) } // getManifestFilePath returns the repo-relative manifest path for use in the diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 0594ada..623c540 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -84,6 +84,11 @@ "type": "string", "description": "cascade CLI version pinned into generated workflows (for example \"v1.0.0\"). Defaults to the pinned release tag when unset or \"latest\"." }, + "cli_version_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "description": "40-character lowercase-hex commit SHA that cli_version resolves to. When set and pin_mode is sha, generated setup-cli self-action refs are pinned to this commit with cli_version carried as a trailing comment. Written by the version-bump automation, which peels the annotated cli_version tag to its commit." + }, "tag_prefix": { "type": "string", "description": "Version tag prefix (default: \"v\")." diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 0594ada..623c540 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -84,6 +84,11 @@ "type": "string", "description": "cascade CLI version pinned into generated workflows (for example \"v1.0.0\"). Defaults to the pinned release tag when unset or \"latest\"." }, + "cli_version_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "description": "40-character lowercase-hex commit SHA that cli_version resolves to. When set and pin_mode is sha, generated setup-cli self-action refs are pinned to this commit with cli_version carried as a trailing comment. Written by the version-bump automation, which peels the annotated cli_version tag to its commit." + }, "tag_prefix": { "type": "string", "description": "Version tag prefix (default: \"v\")."