diff --git a/internal/config/types.go b/internal/config/types.go index 35a45d6e..fe06479b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -243,16 +243,24 @@ func (c *TrunkConfig) ValidateSchemaVersion() (warnings []string, fatalErr error return nil, nil } -// GetCLIVersion returns the configured CLI version. +// DefaultCLIVersion is the immutable cascade release tag pinned into generated +// workflows when cli_version is unset (or set to the mutable "latest"). Bump this +// with each cascade release so generated pipelines track the newest stable tag. +const DefaultCLIVersion = "v0.1.0" + +// GetCLIVersion returns the configured CLI version, resolving mutable refs to the +// immutable pinned default for supply-chain integrity. // Supported values: -// - "" or "latest" → uses the "latest" tag (most recent stable release) -// - "beta" → uses "master" branch (bleeding edge, may be unstable) -// - "vX.Y.Z" → uses a specific version tag +// - "" or "latest" → DefaultCLIVersion (the pinned, immutable release tag) +// - "beta" → passes through; the generator maps it to "master" (bleeding edge) +// - "vX.Y.Z" → passes through as a specific version tag func (c *TrunkConfig) GetCLIVersion() string { - if c.CLIVersion == "" { - return "latest" + switch c.CLIVersion { + case "", "latest": + return DefaultCLIVersion + default: + return c.CLIVersion } - return c.CLIVersion } // GetTagPrefix returns the configured tag prefix or "v" if not specified diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 4a19ce3c..159e2eed 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -391,11 +391,19 @@ func TestGetTriggersForDeploy_BuildWithNoTriggers(t *testing.T) { // ============================================================================= func TestGetCLIVersion(t *testing.T) { - // Default when not set + // Default when not set resolves to the pinned, immutable release tag. cfg := &TrunkConfig{} - assert.Equal(t, "latest", cfg.GetCLIVersion()) + assert.Equal(t, DefaultCLIVersion, cfg.GetCLIVersion()) - // Configured value + // Explicit "latest" is a mutable ref and is pinned to the default tag. + cfg.CLIVersion = "latest" + assert.Equal(t, DefaultCLIVersion, cfg.GetCLIVersion()) + + // "beta" is the explicit opt-in escape hatch and passes through. + cfg.CLIVersion = "beta" + assert.Equal(t, "beta", cfg.GetCLIVersion()) + + // Configured version tag passes through unchanged. cfg.CLIVersion = "v1.2.3" assert.Equal(t, "v1.2.3", cfg.GetCLIVersion()) } diff --git a/internal/generate/action_pins_test.go b/internal/generate/action_pins_test.go index 78e78d67..8109aa00 100644 --- a/internal/generate/action_pins_test.go +++ b/internal/generate/action_pins_test.go @@ -156,7 +156,7 @@ func TestPinPolicy_E2E_OrchestrateEmitsPinnedRefs(t *testing.T) { require.NoError(t, err) // No mutable third-party tag survives. cascade's own setup-cli ref is - // intentionally excluded and may still carry @latest (its CLI version). + // intentionally excluded from SHA pinning and carries its version tag. for _, action := range thirdPartyActions { assert.NotContainsf(t, out, "uses: "+action+"@v", "third-party %s must be SHA-pinned, not tagged", action) assert.NotContainsf(t, out, action+"@latest", "third-party %s must never be @latest", action) @@ -184,6 +184,28 @@ func TestPinPolicy_E2E_OrchestrateEmitsPinnedRefs(t *testing.T) { "cascade-owned action must not be SHA-pinned by the third-party pin table") } +// TestGetCLIRef_DefaultIsPinnedNotMutable asserts that, under a default manifest +// with cli_version unset, the generated self-action ref is the immutable release +// tag (config.DefaultCLIVersion) and never the mutable @latest or @master refs. +// SHA-pinning the self-action is not done at generation time; the version tag is +// the supply-chain pin. +func TestGetCLIRef_DefaultIsPinnedNotMutable(t *testing.T) { + cfg, tmpDir := newPinE2EConfig(t) + cfg.PinMode = "" // default manifest, no pin config + // cli_version intentionally unset. + + out, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + const action = "stablekernel/cascade/.github/actions/setup-cli" + assert.Contains(t, out, action+"@"+config.DefaultCLIVersion, + "self-action must be pinned to the immutable release tag by default") + assert.NotContains(t, out, action+"@latest", + "self-action default ref must not be the mutable @latest") + assert.NotContains(t, out, action+"@master", + "self-action default ref must not be the mutable @master") +} + // TestPinPolicy_E2E_Deterministic confirms re-generating against the same pin // config yields byte-identical output (no network, table-driven). func TestPinPolicy_E2E_Deterministic(t *testing.T) { diff --git a/internal/generate/external.go b/internal/generate/external.go index 356b1a55..1c83f2d0 100644 --- a/internal/generate/external.go +++ b/internal/generate/external.go @@ -21,17 +21,15 @@ func NewExternalUpdateGenerator(cfg *config.TrunkConfig, baseDir string) *Extern } } -// getCLIRef returns the Git ref to use for the cascade actions. +// getCLIRef returns the Git ref 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. +// "beta" is the explicit opt-in escape hatch to the "master" branch. func (g *ExternalUpdateGenerator) getCLIRef() string { - version := g.config.GetCLIVersion() - switch version { - case "latest", "": - return "latest" - case "beta": - return "master" - default: - return version + if g.config.CLIVersion == "beta" { + return "master" // Explicit opt-in escape hatch to trunk. } + return g.config.GetCLIVersion() } // getReleaseTokenRef returns the token expression for release operations. diff --git a/internal/generate/generator.go b/internal/generate/generator.go index c424d37f..7d52ccae 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -177,21 +177,18 @@ func (g *Generator) anyAutoCommits() bool { return false } -// getCLIRef returns the Git ref to use for the cascade actions. +// 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: -// - "latest" → uses the "latest" tag (updated with each stable release) -// - "beta" → uses "master" branch (bleeding edge, may be unstable) -// - "vX.Y.Z" → uses a specific version tag +// - 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 func (g *Generator) getCLIRef() string { - version := g.config.GetCLIVersion() - switch version { - case "latest", "": - return "latest" // Points to the most recent stable release - case "beta": - return "master" // Bleeding edge from trunk - default: - return version // Specific version tag (e.g., v1.0.0) + if g.config.CLIVersion == "beta" { + return "master" // Explicit opt-in escape hatch to trunk. } + return g.config.GetCLIVersion() } // getReleaseTokenRef returns the token expression for release operations. diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index e32e7270..5bca8b59 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -50,17 +50,14 @@ func (g *HotfixGenerator) targetEnvs() []string { } // getCLIRef mirrors the ref-resolution used by the other generators so the -// emitted setup-cli ref tracks config.cli_version. +// emitted setup-cli ref tracks config.cli_version. The default (cli_version +// 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 { - version := g.config.GetCLIVersion() - switch version { - case "latest", "": - return "latest" - case "beta": - return "master" - default: - return version + if g.config.CLIVersion == "beta" { + return "master" // Explicit opt-in escape hatch to trunk. } + return g.config.GetCLIVersion() } // 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 a5d4c9b6..9eb23dc3 100644 --- a/internal/generate/merge_queue.go +++ b/internal/generate/merge_queue.go @@ -38,17 +38,14 @@ func (g *MergeQueueGenerator) Enabled() bool { } // getCLIRef mirrors the ref-resolution used by the other generators so the -// emitted setup-cli ref tracks config.cli_version. +// emitted setup-cli ref tracks config.cli_version. The default (cli_version +// 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 { - version := g.config.GetCLIVersion() - switch version { - case "latest", "": - return "latest" - case "beta": - return "master" - default: - return version + if g.config.CLIVersion == "beta" { + return "master" // Explicit opt-in escape hatch to trunk. } + return g.config.GetCLIVersion() } // 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 c14ed0ac..5626d766 100644 --- a/internal/generate/pr_preview.go +++ b/internal/generate/pr_preview.go @@ -34,17 +34,15 @@ func NewPRPreviewGenerator(cfg *config.TrunkConfig, baseDir string) *PRPreviewGe } } -// getCLIRef returns the Git ref to use for the cascade actions. +// getCLIRef returns the Git ref 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. +// "beta" is the explicit opt-in escape hatch to the "master" branch. func (g *PRPreviewGenerator) getCLIRef() string { - version := g.config.GetCLIVersion() - switch version { - case "latest", "": - return "latest" - case "beta": - return "master" - default: - return version + if g.config.CLIVersion == "beta" { + return "master" // Explicit opt-in escape hatch to trunk. } + return g.config.GetCLIVersion() } // commentEnabled reports whether the preview should also post a PR comment. diff --git a/internal/generate/promote.go b/internal/generate/promote.go index d032f545..e9d3837a 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -39,21 +39,18 @@ func (g *PromoteGenerator) SetState(state map[string]*config.EnvState) { g.state = state } -// getCLIRef returns the Git ref to use for the cascade actions. +// getCLIRef returns the Git ref 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: -// - "latest" → uses the "latest" tag (updated with each stable release) -// - "beta" → uses "master" branch (bleeding edge, may be unstable) -// - "vX.Y.Z" → uses a specific version tag +// - 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 func (g *PromoteGenerator) getCLIRef() string { - version := g.config.GetCLIVersion() - switch version { - case "latest", "": - return "latest" // Points to the most recent stable release - case "beta": - return "master" // Bleeding edge from trunk - default: - return version // Specific version tag (e.g., v1.0.0) + if g.config.CLIVersion == "beta" { + return "master" // Explicit opt-in escape hatch to trunk. } + return g.config.GetCLIVersion() } // getReleaseTokenRef returns the token expression for release operations. diff --git a/internal/generate/release.go b/internal/generate/release.go index fc4c89be..4d914549 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -22,21 +22,18 @@ func NewReleaseGenerator(cfg *config.TrunkConfig, baseDir string) *ReleaseGenera } } -// getCLIRef returns the Git ref to use for the cascade actions. +// getCLIRef returns the Git ref 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: -// - "latest" → uses the "latest" tag (updated with each stable release) -// - "beta" → uses "master" branch (bleeding edge, may be unstable) -// - "vX.Y.Z" → uses a specific version tag +// - 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 func (g *ReleaseGenerator) getCLIRef() string { - version := g.config.GetCLIVersion() - switch version { - case "latest", "": - return "latest" // Points to the most recent stable release - case "beta": - return "master" // Bleeding edge from trunk - default: - return version // Specific version tag (e.g., v1.0.0) + if g.config.CLIVersion == "beta" { + return "master" // Explicit opt-in escape hatch to trunk. } + return g.config.GetCLIVersion() } // getReleaseTokenRef returns the token expression for release operations. diff --git a/internal/generate/validate_check.go b/internal/generate/validate_check.go index a9a7a22c..ede2898f 100644 --- a/internal/generate/validate_check.go +++ b/internal/generate/validate_check.go @@ -34,17 +34,14 @@ func (g *ValidateCheckGenerator) Enabled() bool { } // getCLIRef mirrors the ref-resolution used by the other generators so the -// emitted setup-cli ref tracks config.cli_version. +// emitted setup-cli ref tracks config.cli_version. The default (cli_version +// 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 { - version := g.config.GetCLIVersion() - switch version { - case "latest", "": - return "latest" - case "beta": - return "master" - default: - return version + if g.config.CLIVersion == "beta" { + return "master" // Explicit opt-in escape hatch to trunk. } + return g.config.GetCLIVersion() } // getManifestFilePath returns the repo-relative manifest path for use in the