diff --git a/.github/manifest.yaml b/.github/manifest.yaml index cb819d05..b93dabc7 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: 58c3d309faaf5028aa03900aded8424e738afb81 pin_mode: sha manifest_file: .github/manifest.yaml manifest_key: ci 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[@]} diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index 257ef5e4..50b074aa 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@58c3d309faaf5028aa03900aded8424e738afb81 # 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@58c3d309faaf5028aa03900aded8424e738afb81 # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} - version: v0.1.0 + version: v0.8.0 - name: Generate Changelog id: changelog env: @@ -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 314fd5e1..b73368bd 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@58c3d309faaf5028aa03900aded8424e738afb81 # 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@58c3d309faaf5028aa03900aded8424e738afb81 # 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@58c3d309faaf5028aa03900aded8424e738afb81 # v0.8.0 with: token: ${{ secrets.CASCADE_STATE_TOKEN }} - version: v0.1.0 + version: v0.8.0 - name: Generate Changelog id: changelog env: @@ -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", }, }