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
11 changes: 11 additions & 0 deletions .github/workflows/build-cli.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,14 @@ jobs:
./cascade detect-changes --help
./cascade generate-changelog --help
./cascade generate-workflow --help

# Publish the from-source binary so cascade's own-repo finalize job runs the
# exact commit under release (new CLI capabilities are available in the same
# run that introduces them) instead of a stale pinned release.
- name: Upload CLI binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cascade-cli
path: cascade
if-no-files-found: error
retention-days: 1
12 changes: 12 additions & 0 deletions .github/workflows/orchestrate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,19 @@ jobs:
if [[ "$HAS_OUTPUTS" == "false" ]]; then
echo "_No outputs produced_" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Download cascade CLI (from source)
if: needs.build-cli.result == 'success'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cascade-cli
path: .cascade-bin
- name: Install cascade CLI (from source)
if: needs.build-cli.result == 'success'
run: |
chmod +x .cascade-bin/cascade
echo "$GITHUB_WORKSPACE/.cascade-bin" >> "$GITHUB_PATH"
- name: Setup CLI
if: needs.build-cli.result != 'success'
uses: stablekernel/cascade/.github/actions/setup-cli@58c3d309faaf5028aa03900aded8424e738afb81 # v0.8.0
with:
token: ${{ secrets.CASCADE_STATE_TOKEN }}
Expand Down
12 changes: 12 additions & 0 deletions docs/src/content/docs/internals/release-orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ with the non-triggering `GITHUB_TOKEN`. GoReleaser, running inside Release, then
and publishes the one release for that tag. Nothing pre-creates a draft that GoReleaser
would duplicate, so no orphaned draft is left behind.

In own-repo mode the finalize job also runs the cascade binary built from the exact commit
under release, not a pinned published one. Cascade is self-hosting: the release tool is the
code being released, so a CLI capability added in a commit must be available in the same run
that ships it. Finalize already depends on the `build-cli` callback, which compiles cascade
from source; that job now uploads the binary as a `cascade-cli` artifact, and own-repo
finalize downloads it and puts it on `PATH` for the changelog and `manage-release` steps. A
downstream repository never does this: its finalize installs a pinned released cascade
through `setup-cli`, because a user runs a published binary and never builds cascade from
source. The from-source download is gated on `build-cli` succeeding; on a run where that
callback was skipped, own-repo finalize falls back to the same pinned install so the
changelog step still finds cascade on `PATH`.

This is not a manifest option. Own-repo mode is a CLI flag cascade passes only when
generating and verifying its own workflows, because cascade is the one repository that
self-publishes through GoReleaser; a downstream manifest has no way to reach it, and its
Expand Down
6 changes: 6 additions & 0 deletions e2e/scenarios/46-release-tag-only.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ steps:
- " tag_only: 'true'"
- " token: ${{ secrets.GITHUB_TOKEN }}"
- " - name: Dispatch Release Candidate Build"
# Own-repo finalize runs the binary built from the commit under
# release (from the build-cli callback artifact), not a stale pin,
# so a new CLI capability (for example --tag-only) is present in the
# same run that introduces it.
- " - name: Download cascade CLI (from source)"
- " name: cascade-cli"
- path: ".github/actions/manage-release/action.yaml"
contains:
- " tag_only:"
Expand Down
12 changes: 12 additions & 0 deletions e2e/scenarios/47-release-plain-not-own-repo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,20 @@ steps:
- path: ".github/workflows/orchestrate.yaml"
contains:
- " token: ${{ secrets.CASCADE_STATE_TOKEN }}"
# Plain (downstream) finalize installs a pinned released cascade via
# an unconditional setup-cli step; it never builds from source. The
# harness localizes every `stablekernel/cascade/.github/actions/X@ref`
# to `./.github/actions/X` before this assertion runs (see
# localizeWorkflows), so this checks the step's presence and its
# unconditional shape, not the pre-localize remote ref literal.
- " - name: Setup CLI"
not_contains:
- "tag_only"
- "Download cascade CLI (from source)"
- "cascade-cli"
# Own-repo mode gates the Setup CLI step on build-cli having been
# skipped; plain mode's Setup CLI step must stay unconditional.
- "needs.build-cli.result"
- path: ".github/actions/manage-release/action.yaml"
not_contains:
- "tag_only"
Expand Down
63 changes: 58 additions & 5 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,63 @@ func failureOrCancelledCond(jobName string) string {
return fmt.Sprintf("contains(fromJSON('[\"failure\", \"cancelled\"]'), needs.%s.result)", jobName)
}

// ownRepoCLIArtifactName is the workflow-artifact name under which the build-cli
// callback publishes the cascade binary built from the commit under release, and
// which cascade's own-repo finalize job downloads to run the release. The
// build-cli.yaml callback workflow uploads under this exact name; the two must
// stay in lockstep.
const ownRepoCLIArtifactName = "cascade-cli"

// writeFinalizeCLIBootstrap emits the step(s) that put a cascade binary on PATH
// for the finalize job's changelog and release steps.
//
// Downstream (every user manifest): a single pinned Setup CLI step that installs
// a released cascade via the setup-cli action. This is correct for users, who
// run a published binary and never build cascade from source.
//
// Cascade's own repo (ownRepo): the release must run the binary built from the
// exact commit under release, not a stale published pin, so a newly added CLI
// capability (for example the tag-only manage-release path) is available in the
// same run that introduces it. The build-cli callback compiles that binary and
// uploads it as ownRepoCLIArtifactName; finalize downloads it and prepends it to
// PATH. When build-cli did not run (no release-worthy CLI change, so the release
// path is not exercised), finalize falls back to the pinned released binary so
// the changelog step still has a cascade on PATH.
func (g *Generator) writeFinalizeCLIBootstrap(sb *strings.Builder) {
if !g.ownRepo {
g.writeFinalizePinnedCLI(sb, "")
return
}

sb.WriteString(" - name: Download cascade CLI (from source)\n")
sb.WriteString(" if: needs.build-cli.result == 'success'\n")
writeActionUses(sb, g.config, " ", actionDownloadArtifact)
sb.WriteString(" with:\n")
fmt.Fprintf(sb, " name: %s\n", ownRepoCLIArtifactName)
sb.WriteString(" path: .cascade-bin\n")
sb.WriteString(" - name: Install cascade CLI (from source)\n")
sb.WriteString(" if: needs.build-cli.result == 'success'\n")
sb.WriteString(" run: |\n")
sb.WriteString(" chmod +x .cascade-bin/cascade\n")
sb.WriteString(" echo \"$GITHUB_WORKSPACE/.cascade-bin\" >> \"$GITHUB_PATH\"\n")
// Fallback for runs where the build-cli callback was skipped: install the
// pinned released binary so the changelog step still finds cascade on PATH.
g.writeFinalizePinnedCLI(sb, "needs.build-cli.result != 'success'")
}

// writeFinalizePinnedCLI emits the pinned Setup CLI step. When cond is non-empty
// it is emitted as the step's if: guard.
func (g *Generator) writeFinalizePinnedCLI(sb *strings.Builder, cond string) {
sb.WriteString(" - name: Setup CLI\n")
if cond != "" {
fmt.Fprintf(sb, " if: %s\n", cond)
}
fmt.Fprintf(sb, " uses: stablekernel/cascade/.github/actions/setup-cli@%s\n", g.getCLIRef())
sb.WriteString(" with:\n")
fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef())
fmt.Fprintf(sb, " version: %s\n", g.config.GetCLIVersion())
}

// writeChangelogStep emits the built-in changelog generation as a step inside
// the finalize job. The custom changelog path is NOT a step: a reusable
// workflow cannot be invoked as a step `uses:`, so it is hoisted into its own
Expand All @@ -1857,11 +1914,7 @@ func (g *Generator) writeChangelogStep(sb *strings.Builder) {
}
{
// Use built-in changelog generation
sb.WriteString(" - name: Setup CLI\n")
fmt.Fprintf(sb, " uses: stablekernel/cascade/.github/actions/setup-cli@%s\n", g.getCLIRef())
sb.WriteString(" with:\n")
fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef())
fmt.Fprintf(sb, " version: %s\n", g.config.GetCLIVersion())
g.writeFinalizeCLIBootstrap(sb)
sb.WriteString(" - name: Generate Changelog\n")
sb.WriteString(" id: changelog\n")
sb.WriteString(" env:\n")
Expand Down
51 changes: 51 additions & 0 deletions internal/generate/own_repo_release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,57 @@ func TestGenerator_OwnRepoRelease(t *testing.T) {
}
}

// TestGenerator_OwnRepoFinalizeBuildsFromSource asserts that in own-repo mode the
// finalize job obtains its cascade binary from the build-cli callback artifact
// (the commit under release), not from a pinned setup-cli release, so a newly
// added CLI capability is present in the same run that introduces it. Plain
// generation (every downstream manifest) must keep the pinned setup-cli and never
// download the from-source artifact.
func TestGenerator_OwnRepoFinalizeBuildsFromSource(t *testing.T) {
release := &config.ReleaseConfig{Workflow: ".github/workflows/release.yaml"}

t.Run("own-repo finalize downloads the from-source binary", func(t *testing.T) {
cfg, tmpDir := candidateDispatchConfig(t, true, release)
cfg.ReleaseToken = pat

content, err := NewGenerator(cfg, tmpDir, WithOwnRepoRelease()).Generate()
require.NoError(t, err)

// Scope to the finalize job: from the finalize header to the failure gate.
finalize := blockBetween(t, content, " finalize:", "- name: Check for Failures")
assert.Contains(t, finalize, "- name: Download cascade CLI (from source)",
"own-repo finalize must download the from-source cascade binary")
assert.Contains(t, finalize, "name: "+ownRepoCLIArtifactName,
"own-repo finalize must download the build-cli artifact by name")
assert.Contains(t, finalize, "actions/download-artifact@",
"own-repo finalize must use the pinned download-artifact action")
assert.Contains(t, finalize, "if: needs.build-cli.result == 'success'",
"the from-source download must be gated on the build-cli callback succeeding")
assert.Contains(t, finalize, "echo \"$GITHUB_WORKSPACE/.cascade-bin\" >> \"$GITHUB_PATH\"",
"the from-source binary must be prepended to PATH")
// The pinned setup-cli survives only as the skipped-build fallback, guarded
// by the complementary condition.
assert.Contains(t, finalize, "if: needs.build-cli.result != 'success'",
"own-repo finalize must retain a pinned fallback when build-cli was skipped")
})

t.Run("plain finalize keeps the pinned setup-cli and never downloads", func(t *testing.T) {
cfg, tmpDir := candidateDispatchConfig(t, true, release)
cfg.ReleaseToken = pat

content, err := NewGenerator(cfg, tmpDir).Generate()
require.NoError(t, err)

finalize := blockBetween(t, content, " finalize:", "- name: Check for Failures")
assert.NotContains(t, finalize, "Download cascade CLI (from source)",
"plain finalize must not download a from-source binary")
assert.NotContains(t, finalize, ownRepoCLIArtifactName,
"plain finalize must carry no trace of the own-repo build artifact")
assert.Contains(t, finalize, "stablekernel/cascade/.github/actions/setup-cli@",
"plain finalize must install the pinned released cascade via setup-cli")
})
}

// TestGenerator_NewGeneratorTwoArgDefaultsToPlainMode is a narrow regression
// lock: the pre-existing two-argument NewGenerator(cfg, baseDir) call every
// caller in the codebase already uses must keep behaving exactly as before
Expand Down