From 7933a1cef7663d4721c0fa3bf6db0b015b251fd1 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 01:14:28 -0400 Subject: [PATCH 1/4] fix(ci): pin the orchestrate and promote bootstrap CLI to a current release orchestrate.yaml and promote.yaml pinned the bootstrap cascade CLI (used to run cascade orchestrate/promote steps against cascade's own release) to v0.1.0, predating the release-tag filtering and prerelease-tolerant version parsing that now ship in v0.8.0. The stale binary could pick a stray prerelease tag as the latest release base and then reject its own version string, breaking a real release cut. Bump cli_version and cli_version_sha in .github/manifest.yaml to v0.8.0 and regenerate both workflows so they carry the fix. Signed-off-by: Joshua Temple --- .github/manifest.yaml | 4 ++-- .github/workflows/orchestrate.yaml | 8 ++++---- .github/workflows/promote.yaml | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/manifest.yaml b/.github/manifest.yaml index cb819d05..9fc131ba 100644 --- a/.github/manifest.yaml +++ b/.github/manifest.yaml @@ -10,8 +10,8 @@ ci: - e2e/** - go.mod - go.sum - cli_version: v0.1.0 - cli_version_sha: 9dc69a1f66753a3865c38c34eca5a931f677c803 + cli_version: v0.8.0 + cli_version_sha: adbd2aa1fe3871a3a02ebf3340927a54693d871b pin_mode: sha manifest_file: .github/manifest.yaml manifest_key: ci diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index 257ef5e4..26dc99fc 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -36,10 +36,10 @@ jobs: with: fetch-depth: 0 - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 + uses: stablekernel/cascade/.github/actions/setup-cli@adbd2aa1fe3871a3a02ebf3340927a54693d871b # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} - version: v0.1.0 + version: v0.8.0 - name: Run Setup id: setup run: | @@ -109,10 +109,10 @@ jobs: echo "_No outputs produced_" >> "$GITHUB_STEP_SUMMARY" fi - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 + uses: stablekernel/cascade/.github/actions/setup-cli@adbd2aa1fe3871a3a02ebf3340927a54693d871b # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} - version: v0.1.0 + version: v0.8.0 - name: Generate Changelog id: changelog env: diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index 314fd5e1..447d6e18 100644 --- a/.github/workflows/promote.yaml +++ b/.github/workflows/promote.yaml @@ -93,10 +93,10 @@ jobs: with: fetch-depth: 0 - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 + uses: stablekernel/cascade/.github/actions/setup-cli@adbd2aa1fe3871a3a02ebf3340927a54693d871b # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} - version: v0.1.0 + version: v0.8.0 - name: Run Preflight id: preflight env: @@ -128,10 +128,10 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 + uses: stablekernel/cascade/.github/actions/setup-cli@adbd2aa1fe3871a3a02ebf3340927a54693d871b # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} - version: v0.1.0 + version: v0.8.0 - name: Validate Promotion env: MODE: ${{ github.event.inputs.mode }} @@ -155,10 +155,10 @@ jobs: with: fetch-depth: 0 - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0 + uses: stablekernel/cascade/.github/actions/setup-cli@adbd2aa1fe3871a3a02ebf3340927a54693d871b # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} - version: v0.1.0 + version: v0.8.0 - name: Generate Changelog id: changelog env: From a219df59d13a4cdbd03a17aadb100e77f8d69f9b Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 01:14:43 -0400 Subject: [PATCH 2/4] fix(release): prune stray dry-run tags left behind by back-to-back dispatches The sweep-dryrun-tags job skipped entirely on any manual dry_run dispatch, to avoid racing the tag that same run had just cut. In practice this switched pruning off for as long as a maintainer kept rehearsing back to back (four consecutive dry-run dispatches today never ran the sweep once), letting dry-run tags pile up until the next scheduled or non-dry-run trigger. The retention logic only ever deletes tags older than the newest few, so the tag a concurrent dispatch just cut (always the newest by run number) can never be among them; the job now always runs. Separately, a dry-run tag deleted out-of-band (its ref removed without also deleting the release object) leaves an orphaned draft release with no tag to find it by again, since discovery was tag-only. The job now also reconciles any dry-run-shaped release with no live tag before applying retention. Signed-off-by: Joshua Temple --- .github/workflows/nightly-release.yaml | 29 +++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/nightly-release.yaml b/.github/workflows/nightly-release.yaml index 17f3c0c8..68077fe7 100644 --- a/.github/workflows/nightly-release.yaml +++ b/.github/workflows/nightly-release.yaml @@ -265,9 +265,13 @@ jobs: # rehearsal is always among the newest) and deletes the older accumulated # ones, so they cannot pile up and pollute version-calc. # - # Always on the schedule; on a manual run only when this is not itself a - # dry run, so it never races the tag the dispatch job just cut. - if: github.event_name == 'schedule' || github.event.inputs.dry_run != 'true' + # Always runs, including on a dry-run dispatch: retention only ever deletes + # tags OLDER than the newest few, so the tag a concurrent dispatch job is + # cutting (always the newest by monotonic run number) can never be among + # them even if this job races ahead of that push. Skipping the whole job on + # a dry-run dispatch (the previous behavior) left pruning off for as long as + # a maintainer kept rehearsing back to back, which is exactly when dry-run + # tags accumulate fastest. runs-on: ubuntu-latest permissions: contents: write @@ -293,6 +297,25 @@ jobs: git fetch --tags --force --prune --prune-tags --quiet origin + # A dry-run release can outlive its tag: if the tag is ever removed + # out-of-band (e.g. a manual `git push --delete` that does not also + # delete the release object), the release is left dangling with no + # ref to find it by. A tag-only scan can never see it again, so it + # accumulates invisibly forever. Reconcile those first: with no live + # tag there is no in-flight rehearsal chain to protect, so retention + # does not apply and each one is deleted unconditionally. + mapfile -t releases < <( + gh release list -L 200 --json tagName --jq '.[].tagName' \ + | grep -E "$dryrun_re" || true + ) + for tag in "${releases[@]}"; do + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null 2>&1; then + continue # has a live tag; handled by the retention pass below + fi + gh release delete "$tag" --yes + echo "::notice::Deleted orphaned dry-run release ${tag} (tag already gone)." + done + mapfile -t tags < <(git tag -l | grep -E "$dryrun_re" | sort -V || true) count=${#tags[@]} From de41183b569103e36d769de7fecd4390e478c4d5 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 01:15:13 -0400 Subject: [PATCH 3/4] fix(ci): dispatch release by workflow name, not a ./-prefixed path orchestrate's Finalize job dispatches the configured release workflow via gh workflow run against normalizeWorkflowPath's output, which produces a "./.github/workflows/x.yaml"-style path. That form is correct for a uses: reusable-workflow reference but gh workflow run wants the bare file name (or a numeric workflow ID) for a same-repo dispatch and 404s on the repo path ("workflow ... not found on the default branch"). Every call site already passes --repo explicitly, so this fallback dispatch has silently always failed; it only didn't matter because the tag-push trigger happened to fire Release directly. That trigger is unreliable by design (a candidate tag can point at a state commit whose message suppresses CI), so this dispatch is the safety net for exactly the case where it is needed. Add workflowDispatchTarget, a bare-file-name resolver dedicated to gh workflow run call sites, and route the three affected dispatches (candidate build, release-build trigger, publish callback) through it instead of normalizeWorkflowPath. Signed-off-by: Joshua Temple --- .github/workflows/orchestrate.yaml | 2 +- .github/workflows/promote.yaml | 2 +- ...8-promote-breaking-gate-release-build.yaml | 2 +- internal/generate/candidate_dispatch_test.go | 13 +++++++----- internal/generate/generator.go | 20 ++++++++++++++++++- internal/generate/promote.go | 4 ++-- .../generate/release_build_dispatch_test.go | 6 +++--- 7 files changed, 35 insertions(+), 14 deletions(-) diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index 26dc99fc..99dace49 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -149,7 +149,7 @@ jobs: # The tag-push trigger is unreliable here: the candidate tag can # point at a state commit whose message suppresses CI, so an explicit # dispatch against the tag is the dependable way to build the candidate. - gh workflow run ./.github/workflows/release.yaml \ + gh workflow run release.yaml \ --repo "${{ github.repository }}" \ --ref "$TAG" - name: Update Manifest diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index 447d6e18..b0a0d8ae 100644 --- a/.github/workflows/promote.yaml +++ b/.github/workflows/promote.yaml @@ -273,7 +273,7 @@ jobs: run: | # Dispatch the configured release-build workflow against the # published tag so it can build and attach release binaries. - gh workflow run ./.github/workflows/release.yaml \ + gh workflow run release.yaml \ --repo "${{ github.repository }}" \ --ref "$TAG" diff --git a/e2e/scenarios/38-promote-breaking-gate-release-build.yaml b/e2e/scenarios/38-promote-breaking-gate-release-build.yaml index 2d7dffcd..debfa854 100644 --- a/e2e/scenarios/38-promote-breaking-gate-release-build.yaml +++ b/e2e/scenarios/38-promote-breaking-gate-release-build.yaml @@ -45,7 +45,7 @@ steps: - " if: ${{ github.event.inputs.dry_run != 'true' }}\n" # Follow-on release-build dispatch against the published tag. - " - name: Trigger Release Build\n" - - " gh workflow run ./.github/workflows/release-build.yaml \\\n" + - " gh workflow run release-build.yaml \\\n" not_contains: # The promote path uses the CLI gate, not the release bash step. - " - name: Check Breaking Changes\n" diff --git a/internal/generate/candidate_dispatch_test.go b/internal/generate/candidate_dispatch_test.go index c8e2fcb8..5437e48b 100644 --- a/internal/generate/candidate_dispatch_test.go +++ b/internal/generate/candidate_dispatch_test.go @@ -76,8 +76,9 @@ func TestGenerator_CandidateDispatchStep(t *testing.T) { require.Contains(t, content, "- name: Dispatch Release Candidate Build", "expected the candidate dispatch step in dispatch mode with a release workflow") body := stepRunBody(t, content, "Dispatch Release Candidate Build") - assert.Contains(t, body, "gh workflow run ./.github/workflows/release.yaml", - "the step must dispatch the configured release workflow, normalized") + assert.Contains(t, body, "gh workflow run release.yaml", + "the step must dispatch the configured release workflow by its bare file name; "+ + "gh workflow run 404s on a \"./\"-prefixed repo path") assert.Contains(t, body, `--ref "$TAG"`, "the dispatch must target the candidate tag") } else { @@ -89,7 +90,9 @@ func TestGenerator_CandidateDispatchStep(t *testing.T) { } // TestGenerator_CandidateDispatchNormalizesBareFilename asserts a bare release -// workflow filename is normalized to a repo-relative path in the dispatch. +// workflow filename dispatches unchanged: gh workflow run addresses a same-repo +// workflow by file name (or numeric ID), never by a "./"-prefixed repository +// path, so an already-bare name needs no further normalization. func TestGenerator_CandidateDispatchNormalizesBareFilename(t *testing.T) { cfg, tmpDir := candidateDispatchConfig(t, true, &config.ReleaseConfig{Workflow: "release.yaml"}) @@ -97,6 +100,6 @@ func TestGenerator_CandidateDispatchNormalizesBareFilename(t *testing.T) { require.NoError(t, err) body := stepRunBody(t, content, "Dispatch Release Candidate Build") - assert.Contains(t, body, "gh workflow run ./.github/workflows/release.yaml", - "a bare release workflow filename must normalize to a repo-relative path") + assert.Contains(t, body, "gh workflow run release.yaml", + "a bare release workflow filename must dispatch unchanged") } diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 6dc057c8..1ec6ae08 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -60,6 +60,24 @@ func normalizeWorkflowPath(path string) string { return "./.github/workflows/" + filepath.Base(path) } +// workflowDispatchTarget returns the value cascade passes as the positional +// target of "gh workflow run" to dispatch a same-repo workflow. Every call site +// in this package always passes --repo explicitly and targets the current +// repository, so gh needs only the bare workflow file name (or a numeric +// workflow ID); the "./"-prefixed repository path normalizeWorkflowPath +// produces is correct for a uses: reusable-workflow reference but 404s against +// gh workflow run ("workflow ... not found on the default branch"). A leading +// "@ref" cross-repo suffix (valid for uses:, meaningless for gh workflow run, +// whose target repo comes from --repo) is stripped defensively so a +// misconfigured external-style value still degrades to a usable file name +// rather than a broken dispatch argument. +func workflowDispatchTarget(path string) string { + if idx := strings.Index(path, "@"); idx != -1 { + path = path[:idx] + } + return filepath.Base(path) +} + // envGHAName returns the GitHub Environment name for a given cascade environment // name. When the config has an EnvironmentConfig entry for that env whose // GHAEnvironment field is non-empty, that value is returned; otherwise the @@ -1925,7 +1943,7 @@ func (g *Generator) writeCandidateDispatchStep(sb *strings.Builder) { sb.WriteString(" # The tag-push trigger is unreliable here: the candidate tag can\n") sb.WriteString(" # point at a state commit whose message suppresses CI, so an explicit\n") sb.WriteString(" # dispatch against the tag is the dependable way to build the candidate.\n") - sb.WriteString(" gh workflow run " + normalizeWorkflowPath(g.config.Release.Workflow) + " \\\n") + sb.WriteString(" gh workflow run " + workflowDispatchTarget(g.config.Release.Workflow) + " \\\n") sb.WriteString(" --repo \"${{ github.repository }}\" \\\n") sb.WriteString(" --ref \"$TAG\"\n") } diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 1882c7ef..3a2a3e1f 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -1236,7 +1236,7 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" run: |\n") sb.WriteString(" # Dispatch the configured release-build workflow against the\n") sb.WriteString(" # published tag so it can build and attach release binaries.\n") - sb.WriteString(" gh workflow run " + normalizeWorkflowPath(g.config.Release.Workflow) + " \\\n") + sb.WriteString(" gh workflow run " + workflowDispatchTarget(g.config.Release.Workflow) + " \\\n") sb.WriteString(" --repo \"${{ github.repository }}\" \\\n") sb.WriteString(" --ref \"$TAG\"\n\n") } @@ -1264,7 +1264,7 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { bName := b.Name sb.WriteString(" ARTIFACT_ID_" + strings.ToUpper(strings.ReplaceAll(bName, "-", "_"))) sb.WriteString("=$(yq eval \".$MANIFEST_KEY.state.$SOURCE_ENV.builds." + bName + ".artifact_id // \\\"\\\"\" \"$MANIFEST_FILE\")\n") - sb.WriteString(" gh workflow run " + normalizeWorkflowPath(g.config.Publish.Workflow) + " \\\n") + sb.WriteString(" gh workflow run " + workflowDispatchTarget(g.config.Publish.Workflow) + " \\\n") sb.WriteString(" --repo \"${{ github.repository }}\" \\\n") sb.WriteString(" --ref \"$NEW_VERSION\" \\\n") sb.WriteString(" -f build_name=" + bName + " \\\n") diff --git a/internal/generate/release_build_dispatch_test.go b/internal/generate/release_build_dispatch_test.go index de0cd6c8..575d029e 100644 --- a/internal/generate/release_build_dispatch_test.go +++ b/internal/generate/release_build_dispatch_test.go @@ -48,13 +48,13 @@ func TestPromoteGenerator_ReleaseBuildDispatch(t *testing.T) { name: "release workflow configured emits a dispatch to it", release: &config.ReleaseConfig{Workflow: ".github/workflows/release-build.yaml"}, wantStep: true, - wantDispatch: "gh workflow run ./.github/workflows/release-build.yaml", + wantDispatch: "gh workflow run release-build.yaml", }, { - name: "bare release workflow filename is normalized", + name: "bare release workflow filename dispatches unchanged", release: &config.ReleaseConfig{Workflow: "release-build.yaml"}, wantStep: true, - wantDispatch: "gh workflow run ./.github/workflows/release-build.yaml", + wantDispatch: "gh workflow run release-build.yaml", }, } From f9a9a562d4a638cfcf6497b6e76602bb23471530 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 01:23:52 -0400 Subject: [PATCH 4/4] fix(ci): pin bootstrap cli to the v0.8.0 commit sha cli_version_sha was set to git rev-parse v0.8.0, which resolves to the annotated tag object, not the commit it points at. cascade's own contract (internal/config/types.go) documents cli_version_sha as the commit SHA a setup-cli@ ref expects; validate_v1.go only regex-checks 40 hex chars, so the wrong object type passed validation silently. Use git rev-parse v0.8.0^{commit} instead, matching the v0.1.0 pin's precedent, and regenerate orchestrate.yaml and promote.yaml so every setup-cli@ # v0.8.0 ref carries the commit. Signed-off-by: Joshua Temple --- .github/manifest.yaml | 2 +- .github/workflows/orchestrate.yaml | 4 ++-- .github/workflows/promote.yaml | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/manifest.yaml b/.github/manifest.yaml index 9fc131ba..b93dabc7 100644 --- a/.github/manifest.yaml +++ b/.github/manifest.yaml @@ -11,7 +11,7 @@ ci: - go.mod - go.sum cli_version: v0.8.0 - cli_version_sha: adbd2aa1fe3871a3a02ebf3340927a54693d871b + cli_version_sha: 58c3d309faaf5028aa03900aded8424e738afb81 pin_mode: sha manifest_file: .github/manifest.yaml manifest_key: ci diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index 99dace49..50b074aa 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@adbd2aa1fe3871a3a02ebf3340927a54693d871b # v0.8.0 + uses: stablekernel/cascade/.github/actions/setup-cli@58c3d309faaf5028aa03900aded8424e738afb81 # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} version: v0.8.0 @@ -109,7 +109,7 @@ jobs: echo "_No outputs produced_" >> "$GITHUB_STEP_SUMMARY" fi - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@adbd2aa1fe3871a3a02ebf3340927a54693d871b # v0.8.0 + uses: stablekernel/cascade/.github/actions/setup-cli@58c3d309faaf5028aa03900aded8424e738afb81 # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} version: v0.8.0 diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index b0a0d8ae..b73368bd 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@adbd2aa1fe3871a3a02ebf3340927a54693d871b # v0.8.0 + uses: stablekernel/cascade/.github/actions/setup-cli@58c3d309faaf5028aa03900aded8424e738afb81 # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} version: v0.8.0 @@ -128,7 +128,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@adbd2aa1fe3871a3a02ebf3340927a54693d871b # v0.8.0 + uses: stablekernel/cascade/.github/actions/setup-cli@58c3d309faaf5028aa03900aded8424e738afb81 # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} version: v0.8.0 @@ -155,7 +155,7 @@ jobs: with: fetch-depth: 0 - name: Setup CLI - uses: stablekernel/cascade/.github/actions/setup-cli@adbd2aa1fe3871a3a02ebf3340927a54693d871b # v0.8.0 + uses: stablekernel/cascade/.github/actions/setup-cli@58c3d309faaf5028aa03900aded8424e738afb81 # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} version: v0.8.0