Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/fleet-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<rc> 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" \
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/orchestrate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/promote.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\")."
Expand Down
13 changes: 13 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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/<tag>^{}'`.

### 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.
Expand Down
18 changes: 18 additions & 0 deletions internal/config/schema_v1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions internal/config/validate_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
24 changes: 24 additions & 0 deletions internal/generate/action_pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "# <version>"
// 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 "<indent>- uses: <ref>\n" line for a third-party
// action, routing the ref through actionRef so the pin policy is applied. Pass
// the leading indentation (spaces before "- ").
Expand Down
81 changes: 81 additions & 0 deletions internal/generate/action_pins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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@<ref>
// -> ./.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.
Expand Down
5 changes: 1 addition & 4 deletions internal/generate/drift_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 1 addition & 4 deletions internal/generate/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 6 additions & 11 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 1 addition & 4 deletions internal/generate/hotfix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions internal/generate/merge_queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading