From 25ad144fcd611a16afbea55cd16d15b3a5d6036d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 26 Jun 2026 11:53:24 -0700 Subject: [PATCH 1/3] Re-sync release-workflow fixes from template (#195) Apply the #213/#214 fixes (template PRs ptr727/ProjectTemplate#215/#216) adapted to this single-target NuGet repo. #213 - develop leg published as a stable NuGet package: - get-version-task: add a `branch` input and pin GITHUB_REF/GITHUB_REF_NAME for the NBGV step, so PublicRelease is classified against the leg's branch instead of the dispatching ref. Verified locally: GITHUB_REF=develop now yields 1.4.x-g, GITHUB_REF=main yields clean 1.4.x. - Thread `branch` into both get-version callers. - build-release-task: replace the main-only verify step with a validate-release entry gate that fails fast in both directions (main must be public; every other branch must carry a prerelease suffix). #214 - blanket artifact cleanup destroyed diagnostics/build records: - build-release-task: surgically delete only the consumed release-asset--* transfer artifacts after the release is created. - publish-release / test-pull-request: drop the blanket cleanup-artifacts job; grant actions:write to the publish job. retention-days:1 upload backstop confirmed. actionlint clean; CRLF preserved on every touched workflow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build-nugetlibrary-task.yml | 3 +- .github/workflows/build-release-task.yml | 67 ++++++++++++++----- .github/workflows/get-version-task.yml | 13 +++- .github/workflows/publish-release.yml | 28 +------- .github/workflows/test-pull-request.yml | 29 +------- 5 files changed, 67 insertions(+), 73 deletions(-) diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index ba91d21..0fb70e2 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -37,6 +37,7 @@ jobs: secrets: inherit with: ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} build-nugetlibrary: name: Build NuGet library project job @@ -46,7 +47,7 @@ jobs: steps: - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: dotnet-version: 10.x diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 7949fd6..17f8455 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -50,11 +50,38 @@ jobs: secrets: inherit with: ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} + + # Entry gate: validate branch<->version consistency once, before the build jobs, so an NBGV mis-classification fails + # fast instead of after building and publishing. main must be a public release (no prerelease '-'); every other branch + # must carry a prerelease '-' (guards a develop leg being classified public and published as stable). Strip + # '+buildmetadata' first; a '-' there is legitimate, only a '-' in the core/prerelease segment marks a prerelease. + validate-release: + name: Validate release version job + needs: [get-version] + runs-on: ubuntu-latest + steps: + - name: Validate branch and version consistency step + env: + SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} + BRANCH: ${{ inputs.branch }} + run: | + set -euo pipefail + CORE_AND_PRE="${SEMVER2%%+*}" + if [[ "$BRANCH" == "main" ]]; then + if [[ "$CORE_AND_PRE" == *-* ]]; then + echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." + exit 1 + fi + elif [[ "$CORE_AND_PRE" != *-* ]]; then + echo "::error::Prerelease ($BRANCH) version '$SEMVER2' has no prerelease suffix (NBGV classified it public); refusing to publish." + exit 1 + fi build-nugetlibrary: name: Build NuGet library job if: ${{ inputs.enable_nuget }} - needs: [get-version] + needs: [get-version, validate-release] uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: @@ -72,7 +99,7 @@ jobs: # `github: true` still can't create a release. if: ${{ inputs.github && !inputs.smoke }} runs-on: ubuntu-latest - needs: [get-version, build-nugetlibrary] + needs: [get-version, validate-release, build-nugetlibrary] steps: @@ -82,21 +109,6 @@ jobs: with: ref: ${{ needs.get-version.outputs.GitCommitId }} - # Backstop (main only): a public release must not carry a prerelease '-', guarding against NBGV mis-versioning the - # public ref (e.g. a dispatch on a non-default ref) into a malformed "Latest" release. Strip '+buildmetadata' - # first - a '-' there is legitimate; only a '-' in the core/prerelease segment marks a prerelease. - - name: Verify public release version step - if: ${{ inputs.branch == 'main' }} - env: - SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} - run: | - set -euo pipefail - CORE_AND_PRE="${SEMVER2%%+*}" # drop +buildmetadata; a '-' here is the genuine prerelease separator - if [[ "$CORE_AND_PRE" == *-* ]]; then - echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." - exit 1 - fi - # Collect assets by the `release-asset--*` pattern so this step is target-agnostic: subset releases by # deleting the target, not `enable_*: false` (a skipped `needs` job would skip this release job too). The release # step guards `fail_on_unmatched_files: true`, so at least one `release-asset-*` must match; a repo that drops @@ -150,3 +162,24 @@ jobs: LICENSE README.md ./Publish/* + + # Surgical cleanup at the point of consumption: the release-asset--* transfer artifacts now have durable + # copies on the release, so delete them by exact pattern. Best-effort (the release already published): a hiccup + # must not red the job; the retention-days: 1 backstop reaps anything missed. Deletes every matching id. + # Needs the caller to grant `actions: write` (publish-release's publish job does, see B2). + - name: Delete consumed release asset artifacts step + if: ${{ inputs.expect_release_assets }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \ + --jq ".artifacts[] | select(.name | startswith(\"release-asset-${{ inputs.branch }}-\")) | .id"); then + echo "::warning::Could not list run artifacts; retention-days backstop will reap them." + ids="" + fi + for id in $ids; do + gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id" \ + || echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it." + done diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 678a772..7a3bb08 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -9,6 +9,12 @@ on: required: false type: string default: '' + # Logical branch NBGV classifies against. Pins PublicRelease to this branch instead of the runner's GITHUB_REF, + # which on a publish dispatched from the default branch is that branch for every matrix leg. Empty keeps GITHUB_REF. + branch: + required: false + type: string + default: '' outputs: SemVer2: value: ${{ jobs.get-version.outputs.SemVer2 }} @@ -37,7 +43,7 @@ jobs: steps: - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: dotnet-version: 10.x @@ -52,3 +58,8 @@ jobs: - name: Run Nerdbank.GitVersioning tool step id: nbgv uses: dotnet/nbgv@master + env: + # NBGV reads the branch from GITHUB_REF; pin it to the leg being versioned so a publish dispatched from the + # default branch doesn't classify every leg as the public ref and strip its prerelease suffix. + GITHUB_REF: ${{ inputs.branch != '' && format('refs/heads/{0}', inputs.branch) || github.ref }} + GITHUB_REF_NAME: ${{ inputs.branch != '' && inputs.branch || github.ref_name }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 8e8cbf0..5dd7e33 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -78,6 +78,8 @@ jobs: secrets: inherit permissions: contents: write + # actions:write lets the github-release job delete the release-asset-* artifacts it consumes (surgical cleanup). + actions: write with: ref: ${{ matrix.branch }} branch: ${{ matrix.branch }} @@ -96,29 +98,3 @@ jobs: secrets: inherit permissions: contents: write - - # Delete the run's artifacts (durable copies live on the GitHub release) to keep them off the account storage quota. - cleanup-artifacts: - name: Delete workflow artifacts job - needs: [setup, publish, date-badge] - if: ${{ always() && needs.setup.outputs.publish == 'true' }} - runs-on: ubuntu-latest - permissions: - actions: write - steps: - - name: Delete workflow artifacts step - # Best-effort housekeeping must never fail the run. - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if ! ids=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --paginate \ - --jq '.artifacts[].id'); then - echo "::warning::Could not list run artifacts; skipping cleanup." - ids="" - fi - for artifact_id in $ids; do - gh api --method DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" \ - || echo "::warning::Failed to delete artifact $artifact_id; continuing." - done diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 2d43a30..7d64053 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -49,7 +49,7 @@ jobs: steps: - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: dotnet-version: 10.x @@ -115,30 +115,3 @@ jobs: # smoke-build may be legitimately skipped (library unchanged); only failure/cancelled blocks. exit_on_result "unit-test" "${{ needs.unit-test.result }}" exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" - - # Delete any incidental artifacts a build step emitted to keep them off the account storage quota. Kept out of - # `check-workflow-status`'s needs so housekeeping never gates the required merge check. - cleanup-artifacts: - name: Delete workflow artifacts job - needs: [smoke-build] - if: always() - runs-on: ubuntu-latest - permissions: - actions: write - steps: - - name: Delete workflow artifacts step - # Best-effort housekeeping must never fail the run. - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if ! ids=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --paginate \ - --jq '.artifacts[].id'); then - echo "::warning::Could not list run artifacts; skipping cleanup." - ids="" - fi - for artifact_id in $ids; do - gh api --method DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" \ - || echo "::warning::Failed to delete artifact $artifact_id; continuing." - done From 948ee3b85af3435f58f503993c7f474f848aa991 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 26 Jun 2026 11:57:51 -0700 Subject: [PATCH 2/3] Drop doc-relative 'see B2' from the cleanup step comment The reference was an instruction-doc artifact (#195 section label), not present in the template's own comment. Match the template verbatim so the comment is self-contained in-repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build-release-task.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 17f8455..eaec691 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -166,7 +166,7 @@ jobs: # Surgical cleanup at the point of consumption: the release-asset--* transfer artifacts now have durable # copies on the release, so delete them by exact pattern. Best-effort (the release already published): a hiccup # must not red the job; the retention-days: 1 backstop reaps anything missed. Deletes every matching id. - # Needs the caller to grant `actions: write` (publish-release's publish job does, see B2). + # Needs the caller to grant `actions: write` (publish-release's publish job does). - name: Delete consumed release asset artifacts step if: ${{ inputs.expect_release_assets }} continue-on-error: true From 7da4efb14454d7001f2b482ee5880661ac93617a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 26 Jun 2026 12:29:26 -0700 Subject: [PATCH 3/3] Re-sync ProjectTemplate#218: gate asset delete on the create/refresh condition Pull down the template fix for the edge case reported as ProjectTemplate#217: the surgical delete step now gates on the same `exists=='false' || workflow_dispatch` condition as the create step, so a scheduled re-run on an existing tag no longer deletes the freshly built artifacts (retention-days: 1 reaps them instead). Re-synced verbatim from template PR ptr727/ProjectTemplate#218, which also clears the condensed-comment drift from the original #195 snippet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build-release-task.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index eaec691..6633ec5 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -164,11 +164,15 @@ jobs: ./Publish/* # Surgical cleanup at the point of consumption: the release-asset--* transfer artifacts now have durable - # copies on the release, so delete them by exact pattern. Best-effort (the release already published): a hiccup - # must not red the job; the retention-days: 1 backstop reaps anything missed. Deletes every matching id. - # Needs the caller to grant `actions: write` (publish-release's publish job does). + # copies on the release, so delete them by exact pattern to free the storage quota - scoped to this branch's + # assets, leaving diagnostics and any other artifacts. Gated to the same condition as the create step so it only + # deletes when a release was actually created/refreshed this run; on a skipped create (existing tag, no new + # commits) the fresh artifacts stay for the run, reaped by the retention-days: 1 backstop. Needs the caller to + # grant `actions: write` (publish-release's publish job does). - name: Delete consumed release asset artifacts step - if: ${{ inputs.expect_release_assets }} + if: ${{ inputs.expect_release_assets && (steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch') }} + # Best-effort: the release is already published, so a listing/delete hiccup must never red the job; the + # retention-days: 1 backstop reaps anything missed. Deletes every matching id (a rerun can upload duplicates). continue-on-error: true env: GH_TOKEN: ${{ github.token }}