From dac7b33e910aa261f3a648b27867c5074adb7c5f Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 19:11:12 -0400 Subject: [PATCH 1/2] fix(release): run own-repo finalize from the source-built cascade binary Cascade's own-repo orchestrate finalize installed cascade via a pinned setup-cli release (v0.8.0) whose binary predates --tag-only, so every rc cut failed at the Manage Release step with 'unknown flag: --tag-only'. No published cascade carries --tag-only yet, so bumping the pin cannot fix it. Own-repo finalize now runs the binary built from the exact commit under release: the build-cli callback uploads its from-source binary as a cascade-cli artifact, and finalize downloads it onto PATH for the changelog and manage-release steps (falling back to the pinned install when build-cli was skipped). Cascade self-hosts, so a new CLI capability must be available in the same run that ships it. Scoped to own-repo generation only; downstream user output is unchanged. Regenerated the committed orchestrate.yaml and extended scenarios 46/47. Signed-off-by: Joshua Temple --- .github/workflows/build-cli.yaml | 11 ++++ .github/workflows/orchestrate.yaml | 12 ++++ .../docs/internals/release-orchestration.md | 12 ++++ e2e/scenarios/46-release-tag-only.yaml | 6 ++ .../47-release-plain-not-own-repo.yaml | 6 ++ internal/generate/generator.go | 63 +++++++++++++++++-- internal/generate/own_repo_release_test.go | 51 +++++++++++++++ 7 files changed, 156 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-cli.yaml b/.github/workflows/build-cli.yaml index 2a95c266..6cbcf7b1 100644 --- a/.github/workflows/build-cli.yaml +++ b/.github/workflows/build-cli.yaml @@ -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 diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index af1470dd..1fd840e5 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -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 }} diff --git a/docs/src/content/docs/internals/release-orchestration.md b/docs/src/content/docs/internals/release-orchestration.md index d9c77fbb..8a9b7f99 100644 --- a/docs/src/content/docs/internals/release-orchestration.md +++ b/docs/src/content/docs/internals/release-orchestration.md @@ -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 diff --git a/e2e/scenarios/46-release-tag-only.yaml b/e2e/scenarios/46-release-tag-only.yaml index 55da2d13..e5829b63 100644 --- a/e2e/scenarios/46-release-tag-only.yaml +++ b/e2e/scenarios/46-release-tag-only.yaml @@ -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:" diff --git a/e2e/scenarios/47-release-plain-not-own-repo.yaml b/e2e/scenarios/47-release-plain-not-own-repo.yaml index d61a62a9..4f6c95fa 100644 --- a/e2e/scenarios/47-release-plain-not-own-repo.yaml +++ b/e2e/scenarios/47-release-plain-not-own-repo.yaml @@ -42,8 +42,14 @@ steps: - path: ".github/workflows/orchestrate.yaml" contains: - " token: ${{ secrets.CASCADE_STATE_TOKEN }}" + # Plain (downstream) finalize installs a pinned released cascade via + # setup-cli; it never builds from source. + - " - name: Setup CLI" + - "stablekernel/cascade/.github/actions/setup-cli@" not_contains: - "tag_only" + - "Download cascade CLI (from source)" + - "cascade-cli" - path: ".github/actions/manage-release/action.yaml" not_contains: - "tag_only" diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 6f2e5800..ef7ae93c 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -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 @@ -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") diff --git a/internal/generate/own_repo_release_test.go b/internal/generate/own_repo_release_test.go index 5377d224..551f2103 100644 --- a/internal/generate/own_repo_release_test.go +++ b/internal/generate/own_repo_release_test.go @@ -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 From d52b2070640ca3ac304e5e3eedb6a0f333ff1663 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 19:36:02 -0400 Subject: [PATCH 2/2] test(e2e): fix scenario 47's assertion to survive harness localization Scenario 47's added regression check asserted the literal 'stablekernel/cascade/.github/actions/setup-cli@' substring in generated orchestrate.yaml, but the e2e harness's localizeWorkflows step unconditionally rewrites every such reference to a local './.github/actions/X' path for ALL scenarios before assertions run (harness.go's actionLocalizeSedExpr). That substring can never survive localization, so the assertion was always going to fail regardless of generator behavior; it was not a real regression in plain (non-own-repo) finalize output. Verified the real generator output is unaffected: a direct 'cascade generate-workflow' diff between origin/main's binary and this branch's, for the same plain manifest, is byte-for-byte identical. Replaced the assertion with localization-safe checks: the Setup CLI step's presence, and the absence of the own-repo-only build-cli if-gate ('needs.build-cli.result') and from-source download lines. Scenarios 46 and 47 now pass against the real act+gitea harness. Signed-off-by: Joshua Temple --- e2e/scenarios/47-release-plain-not-own-repo.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/e2e/scenarios/47-release-plain-not-own-repo.yaml b/e2e/scenarios/47-release-plain-not-own-repo.yaml index 4f6c95fa..3c986640 100644 --- a/e2e/scenarios/47-release-plain-not-own-repo.yaml +++ b/e2e/scenarios/47-release-plain-not-own-repo.yaml @@ -43,13 +43,19 @@ steps: contains: - " token: ${{ secrets.CASCADE_STATE_TOKEN }}" # Plain (downstream) finalize installs a pinned released cascade via - # setup-cli; it never builds from source. + # 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" - - "stablekernel/cascade/.github/actions/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"