From 70b8d0ba2d4e5429b7eb5bdad799639e12ef532c Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Fri, 29 May 2026 18:41:45 +1200 Subject: [PATCH] ci: tag-triggered + multi-channel fan-out for release.yml (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors .github/workflows/release.yml from the workflow_dispatch-only stopgap (#268) into the proper release-branch model shape per RFC #267 and milestone #13. ## Trigger - push: tags 'v*' is the primary trigger. The new validate-ref job verifies the tag is reachable from a release/v* branch and fails fast otherwise. - workflow_dispatch with a required `tag` input remains as a fallback for re-runs after a transient API failure. --skip-duplicate on each publish step keeps re-runs idempotent. ## Job graph validate-ref (skipped on workflow_dispatch) ↓ test-and-pack (Test + Pack via `dotnet fallout`, uploads output/packages/*.nupkg as Actions artifact) ↓ ↓ ↓ (fan-out) publish-nuget-org (env: nuget-org, Fallout.*.nupkg → nuget.org, gated) publish-github-packages (env: github-packages, Nuke.*.nupkg → GH Packages, unguarded) publish-github-releases (env: github-releases, all *.nupkg → GH Release, unguarded) Each publish job declares `environment:` so a per-channel deployment record appears under the Deployments tab. The nuget-org env requires approval (set in #272); the other two are unguarded. ## Channel routing - nuget.org gets ONLY Fallout.* — Nuke.* shim IDs belong to the original NUKE maintainer on nuget.org (#47). - GitHub Packages gets the Nuke.* shims — preserves the existing behaviour from the previous workflow. - GitHub Releases gets all *.nupkg attached to the tag's release page, with auto-generated notes via `gh release create --generate-notes`. Idempotent — if the release already exists (workflow_dispatch retry), uploads/replaces assets via --clobber. ## Tag protection ruleset (applied separately via API) A repository ruleset now blocks creation/deletion/update of tags matching v* for non-admins. Bypass actors: RepositoryRole 5 (admin). Combined with the env approval gate on nuget-org, this gates production releases at two layers. The ruleset is configured via API rather than in this YAML (ruleset:17017817). It was created in the same change but lives in repo Settings → Rules, not in the workflow file. ## Build.cs not touched The existing `IPublish.Publish` target and `ICreateGitHubRelease` from Build.cs are bypassed by this workflow — CI now calls `dotnet nuget push` and `gh release create` directly. The targets are still available for local invocation; consolidating or removing them is a follow-up. ## Docs docs/agents/release-and-versioning.md "Release pipeline" section rewritten to describe the new shape (trigger, jobs, channel routing, tag protection, fallback dispatch). CHANGELOG entry deferred to the umbrella docs PR #276 per the pattern set in earlier sub-issues — one consolidated v11-restructure entry rather than fragmented per-PR entries. Refs #274, milestone #13. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 219 ++++++++++++++++++++------ docs/agents/release-and-versioning.md | 20 ++- 2 files changed, 186 insertions(+), 53 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d80651116..651106690 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,54 +2,73 @@ # rationale (lets us bridge GitHub's NUGET_API_KEY secret to Fallout's # NuGetApiKey parameter, which would otherwise have to share a name). # -# STOPGAP (2026-05-29, #267 / milestone #13): -# Trigger is workflow_dispatch only — auto-publish on push to main has been -# disabled because it was firing a Fallout.* release on every merge -# (Dependabot noise across the userbase: ~20 patch versions in recent days -# for what was mostly internal cleanup). The proper shape — tag-triggered -# publishes on `release/vN` branches with multi-channel GitHub Environments — -# is being implemented under milestone #13 (RFC #267). Until that lands, -# releases happen manually: navigate to Actions → release → "Run workflow" -# on the desired ref (main for now; `release/v11` once cut). +# Shape (post #274, milestone #13): +# - Trigger: tag push `v*` on a `release/v*` branch. Tag protection ruleset +# restricts who can create those tags to repo admins. +# - Optional fallback: workflow_dispatch with an existing-tag input, for +# re-runs after a transient publish-API failure. +# - validate-ref → test-and-pack → fan out to three publish jobs in +# parallel, one per GitHub Environment / Tier: +# - publish-nuget-org (Tier 1, approval-gated, Fallout.* only) +# - publish-github-packages (Tier 2, Nuke.* transition shims) +# - publish-github-releases (artifact bundling on the tag's GH Release) +# +# Tier philosophy (RFC #267): nuget.org = production-grade & slow, +# GitHub Packages = bleeding edge / beta, GitHub Releases = bundled artifacts. +# Channel decisions encoded in the per-job `dotnet nuget push` glob patterns. name: release on: + push: + tags: + - 'v*' + # Fallback for re-running a partial publish after a transient API failure. + # Pick an existing tag — the workflow checks it out and re-runs the publish + # fan-out. `--skip-duplicate` on each push makes re-runs idempotent. workflow_dispatch: inputs: - ref: - description: 'Ref to release from (defaults to current branch; will be release/v11 once cut per #267)' - required: false - default: 'main' + tag: + description: 'Existing tag to (re-)release (e.g. v11.0.5)' + required: true permissions: - contents: write # for ICreateGitHubRelease (tag + GitHub release) - packages: write # for the Nuke.* transition-shim push to GH Packages (#47) + contents: read jobs: - release: - name: release + validate-ref: + name: validate ref runs-on: ubuntu-latest - # Declares the job as deploying to the `nuget-org` GitHub Environment. - # Effects: - # - `secrets.NUGET_API_KEY` resolves from the env-scoped secret rather - # than the repo-scoped one (per #273 — repo secret deleted after this - # migration verifies clean). - # - The environment's required-reviewer rule fires before the job runs. - # Currently @ChrisonSimtian is the sole reviewer (#272). - # - A deployment record is created on the GitHub Environment, visible - # under Deployments tab. - # When #274 lands the tag-trigger + multi-channel split, this `environment:` - # declaration moves down to a dedicated publish-nuget-org step; Test+Pack - # become unguarded. - environment: - name: nuget-org - url: https://www.nuget.org/profiles/Fallout + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: 'Verify tag is reachable from a release/v* branch' + run: | + set -euo pipefail + TAG_SHA="${{ github.sha }}" + REACHABLE=$(git branch -r --contains "$TAG_SHA" | grep -E 'origin/release/v[0-9]+' || true) + if [ -z "$REACHABLE" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} at $TAG_SHA is not reachable from any release/v* branch." + echo "Tag-triggered releases only fire from release/v* branches." + exit 1 + fi + echo "Tag ${GITHUB_REF_NAME} validated. Reachable from:" + echo "$REACHABLE" + + test-and-pack: + name: test + pack + runs-on: ubuntu-latest + needs: [validate-ref] + # Run when validate-ref succeeded (tag push) OR was skipped (workflow_dispatch). + if: always() && (needs.validate-ref.result == 'success' || needs.validate-ref.result == 'skipped') steps: - uses: actions/checkout@v6 with: - fetch-depth: 0 # Nerdbank.GitVersioning needs full history - submodules: recursive # vendor/vs-solutionpersistence + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 # Nerdbank.GitVersioning needs full history + submodules: recursive # vendor/vs-solutionpersistence - name: 'Cache: .fallout/temp, ~/.nuget/packages' uses: actions/cache@v4 with: @@ -63,34 +82,130 @@ jobs: global-json-file: global.json - name: 'Restore: dotnet tools' run: dotnet tool restore - - name: 'Run: Test, Pack, Publish' - run: dotnet fallout Test Pack Publish - env: - # Publish target is nuget.org. NUGET_API_KEY is an API key scoped to - # push Fallout.* packages. After #273 lands the secret lives on the - # `nuget-org` GitHub Environment (declared above) — the env scoping - # is what gates the approval step. Before #273, the same secret name - # resolved from the repo-level scope. - NuGetApiKey: ${{ secrets.NUGET_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: 'Run: Test + Pack' + run: dotnet fallout Test Pack + - name: 'Upload: package artifacts' + uses: actions/upload-artifact@v4 + with: + name: packages + path: output/packages/*.nupkg + retention-days: 7 + if-no-files-found: error + + # Tier 1 — production. Approval-gated via the nuget-org GitHub Environment. + # Only Fallout.* packages go here; Nuke.* shim IDs are owned by the original + # NUKE maintainer on nuget.org and are routed to GitHub Packages instead. + publish-nuget-org: + name: publish → nuget.org + runs-on: ubuntu-latest + needs: [test-and-pack] + environment: + name: nuget-org + url: https://www.nuget.org/profiles/Fallout + steps: + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + - name: 'Download: package artifacts' + uses: actions/download-artifact@v4 + with: + name: packages + path: output/packages + - name: 'Push: Fallout.* to nuget.org' + run: | + set -euo pipefail + shopt -s nullglob + packages=(output/packages/Fallout.*.nupkg) + if [ ${#packages[@]} -eq 0 ]; then + echo "::error::No Fallout.* packages found in artifact — nothing to publish." + exit 1 + fi + for pkg in "${packages[@]}"; do + echo "Pushing $pkg to nuget.org..." + dotnet nuget push "$pkg" \ + --source "https://api.nuget.org/v3/index.json" \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --skip-duplicate + done - # Nuke.* transition-shim packages are intentionally filtered out of the - # main Publish step (see Build.cs PushPackageFiles) because that ID - # belongs to NUKE's original maintainer on nuget.org. We publish them - # here to GitHub Packages instead. #47. - - name: 'Publish: Nuke.* transition shims to GH Packages' - if: always() # still push the shims even if a previous Fallout.* push transient-failed + # Tier 2 — bleeding edge / beta. The Nuke.* transition shims live here per + # #47 (we don't own the Nuke.* IDs on nuget.org). Future-direction: also + # publish Fallout.* beta builds here on a faster cadence than nuget.org. + publish-github-packages: + name: publish → GitHub Packages + runs-on: ubuntu-latest + needs: [test-and-pack] + permissions: + packages: write + environment: + name: github-packages + url: https://github.com/ChrisonSimtian/Fallout/packages + steps: + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + - name: 'Download: package artifacts' + uses: actions/download-artifact@v4 + with: + name: packages + path: output/packages + - name: 'Push: Nuke.* transition shims to GitHub Packages' run: | + set -euo pipefail shopt -s nullglob shims=(output/packages/Nuke.*.nupkg) if [ ${#shims[@]} -eq 0 ]; then - echo "No Nuke.* shim packages found in output/packages — skipping." + echo "No Nuke.* shim packages found — skipping." exit 0 fi for pkg in "${shims[@]}"; do - echo "Pushing $pkg to GH Packages..." + echo "Pushing $pkg to GitHub Packages..." dotnet nuget push "$pkg" \ --source "https://nuget.pkg.github.com/ChrisonSimtian/index.json" \ --api-key "${{ secrets.GITHUB_TOKEN }}" \ --skip-duplicate done + + # Bundled artifact distribution. Attaches all nupkgs to the GitHub Release + # for the tag. Idempotent: if the release already exists (workflow_dispatch + # retry case), uploads or replaces missing assets via --clobber. + publish-github-releases: + name: publish → GitHub Releases + runs-on: ubuntu-latest + needs: [test-and-pack] + permissions: + contents: write + environment: + name: github-releases + url: https://github.com/ChrisonSimtian/Fallout/releases + steps: + - uses: actions/checkout@v6 + - name: 'Download: package artifacts' + uses: actions/download-artifact@v4 + with: + name: packages + path: output/packages + - name: 'Create or update GitHub Release with package artifacts' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ inputs.tag }}" + else + TAG="${GITHUB_REF_NAME}" + fi + echo "Tag: $TAG" + if gh release view "$TAG" > /dev/null 2>&1; then + echo "Release $TAG already exists — uploading assets with --clobber." + gh release upload "$TAG" output/packages/*.nupkg --clobber + else + echo "Creating new release $TAG." + gh release create "$TAG" \ + --title "$TAG" \ + --target "$(git rev-parse HEAD)" \ + --generate-notes \ + output/packages/*.nupkg + fi diff --git a/docs/agents/release-and-versioning.md b/docs/agents/release-and-versioning.md index a310de3d7..533811f92 100644 --- a/docs/agents/release-and-versioning.md +++ b/docs/agents/release-and-versioning.md @@ -73,7 +73,25 @@ If you only discover the breaking nature mid-review, apply all relevant steps be ## Release pipeline -`.github/workflows/release.yml` — currently `workflow_dispatch`-triggered only (stopgap per [#268](https://github.com/ChrisonSimtian/Fallout/pull/268) while the tag-triggered shape lands under [#274](https://github.com/ChrisonSimtian/Fallout/issues/274)). Manual runs go via Actions → `release` → "Run workflow". Once #274 ships, the trigger flips to `push: tags: v*` on `release/v*` branches with three GitHub Environments (`nuget-org`, `github-packages`, `github-releases`). Either shape runs the same three-step body (`actions/setup-dotnet` → `dotnet tool restore` → `dotnet fallout Test Pack Publish`). **Publishes to nuget.org** (`https://api.nuget.org/v3/index.json`) under the `Fallout.*` package ID prefix, using the `NUGET_API_KEY` secret (currently a repo secret; will move to the `nuget-org` environment per [#273](https://github.com/ChrisonSimtian/Fallout/issues/273)). Prefix reservation tracked in [#33](https://github.com/ChrisonSimtian/Fallout/issues/33). +`.github/workflows/release.yml` is **tag-triggered**: pushing a `v*` tag on a `release/v*` branch fires the pipeline. The workflow validates the tag is reachable from a `release/v*` branch, then fans out a Test+Pack job to three parallel publish jobs, one per GitHub Environment / channel tier: + +| Job | Environment | Channel | What ships | Gating | +|---|---|---|---|---| +| `publish-nuget-org` | `nuget-org` | Tier 1 — production | `Fallout.*.nupkg` to https://api.nuget.org/v3/index.json | Approval-gated (maintainer reviewer) | +| `publish-github-packages` | `github-packages` | Tier 2 — bleeding edge | `Nuke.*.nupkg` transition shims to https://nuget.pkg.github.com/ChrisonSimtian/index.json | None | +| `publish-github-releases` | `github-releases` | Bundled artifacts | All `*.nupkg` attached to a GitHub Release on the tag, auto-generated notes | None | + +`Nuke.*` shim packages are routed to GitHub Packages, not nuget.org — those package IDs are owned by the original NUKE maintainer on nuget.org (see [#47](https://github.com/ChrisonSimtian/Fallout/issues/47)). + +Each `dotnet nuget push` uses `--skip-duplicate`, so re-runs of a partial publish (one channel failed transiently) are idempotent on the channels that already succeeded. + +**Tag protection.** `v*` tags are protected via a repository ruleset (rules: creation, deletion, update). Bypass actors: repo admins only. Non-admins cannot create release tags — combined with the env approval gate on `nuget-org`, this gates "who can fire a production release" at two layers. + +**Fallback trigger: `workflow_dispatch`.** Manual runs accept an existing-tag input — used to re-run the publish fan-out after a transient API failure. The workflow checks out the specified tag and re-runs the publish chain. `--skip-duplicate` keeps the re-run safe. + +**Channel philosophy** (per [RFC #267](https://github.com/ChrisonSimtian/Fallout/issues/267)): nuget.org is slow/stable/production; GitHub Packages is faster/beta/bleeding-edge; GitHub Releases bundles artifacts on the tag. A planned Tier 3 (Docker-based local NuGet server for pre-merge testing) is tracked in [#279](https://github.com/ChrisonSimtian/Fallout/issues/279). + +`NUGET_API_KEY` is scoped to the `nuget-org` GitHub Environment (per [#273](https://github.com/ChrisonSimtian/Fallout/issues/273)) — only resolves in the gated job. Prefix reservation tracked in [#33](https://github.com/ChrisonSimtian/Fallout/issues/33). ## Adding a new `Fallout.X` package — first-publish gotcha