From 26bf6f55c5f7218ad471201761b333454ff67f4a Mon Sep 17 00:00:00 2001 From: Jay Tuley Date: Tue, 22 Sep 2026 22:31:28 -0500 Subject: [PATCH 1/2] Release to nuget.org from a v* tag, through Trusted Publishing No long-lived API key in a secret: NuGet/login exchanges the workflow's OIDC token for a temporary one, and NUGET_USER is an account name rather than a credential. It needs a Trusted Publishing policy on nuget.org for this repository and this exact workflow filename, so renaming the file breaks publishing until the policy is updated. It republishes the packages build.yml already produced for that commit rather than packing again, so what reaches nuget.org is the artifact that run's tests passed against. Hence the workflow_run trigger, and hence build.yml now runs on v* tags and uploads its packages. Two guards, because publishing cannot be undone: the triggering ref is checked to be a real tag, since head_branch is the ref name for either kind of push and a branch named "v-something" would otherwise qualify; and the Release is created with --verify-tag. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016mwfq4oeZW8SiD4HjTHYdg --- .github/workflows/build.yml | 15 +++++- .github/workflows/release.yml | 93 +++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9153ce..d575d8a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: # to both ran every PR twice. A branch with no PR gets no run, which is the trade. push: branches: [master] + tags: ['v*'] pull_request: workflow_dispatch: @@ -302,7 +303,11 @@ jobs: publish: name: publish prerelease needs: [build, wasm] - if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request' + # Also on a v* tag, where the push to GitHub Packages is a no-op against an existing + # version but the packed artifact is what release.yml then sends to nuget.org. + if: >- + (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) + && github.event_name != 'pull_request' runs-on: ubuntu-latest steps: @@ -321,3 +326,11 @@ jobs: - name: Push to GitHub Packages run: dotnet nuget push 'packages/*.nupkg' --source https://nuget.pkg.github.com/ekonbenefits/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate + + # release.yml republishes exactly these to nuget.org when the commit is tagged, rather + # than packing again, so what ships is the artifact this run's tests passed against. + - name: Upload the packages + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: packages/*.nupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6ef62a5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,93 @@ +# Publishes to nuget.org when a v* tag is pushed, and creates the GitHub Release. +# +# It republishes the packages build.yml's own prerelease job produced for that commit rather +# than packing a second time, so what reaches nuget.org is the artifact that run's tests passed +# against - not a separately-produced, almost-certainly-identical set that never went through +# them. That is why it triggers off build.yml completing rather than off the tag directly. +# +# A workflow_run trigger is always read from the default branch, whatever is in the tag being +# released, so this file has to be on master before it will fire for any future tag. +# +# Authentication is nuget.org Trusted Publishing (OIDC): no long-lived API key in a secret, +# only NUGET_USER, which is a plain account name rather than a credential. It requires a +# Trusted Publishing policy on nuget.org for this exact repository AND this exact workflow +# filename - so renaming this file breaks publishing until the policy is updated to match. +name: release + +on: + workflow_run: + workflows: [build] + types: [completed] + workflow_dispatch: + +jobs: + publish: + name: publish to nuget.org + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v')) + runs-on: ubuntu-latest + permissions: + id-token: write # the OIDC token Trusted Publishing exchanges for a temporary key + contents: write # creating the Release + + steps: + # head_branch is the ref name for either kind of push, so a *branch* called "v-something" + # would otherwise satisfy the condition above. Publishing is irreversible; check. + - name: Verify the triggering ref is a real tag + env: + GH_TOKEN: ${{ github.token }} + REF_NAME: ${{ github.event.workflow_run.head_branch }} + run: | + set -euo pipefail + if ! gh api "repos/${{ github.repository }}/git/ref/tags/$REF_NAME" >/dev/null 2>&1; then + echo "::error::'$REF_NAME' is not a tag in this repository - refusing to publish." + exit 1 + fi + echo "Confirmed '$REF_NAME' is a tag." + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Download the packages that run's tests passed against + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: packages + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Show what is about to be published + run: ls -l packages + + - name: NuGet login (OIDC -> temporary API key) + uses: NuGet/login@v1 + id: login + with: + user: ${{ secrets.NUGET_USER }} + + - name: Push to nuget.org + shell: bash + run: | + set -e + for pkg in packages/*.nupkg; do + dotnet nuget push "$pkg" \ + --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + done + + - name: Create the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + REF_NAME: ${{ github.event.workflow_run.head_branch }} + run: | + set -e + gh release create "$REF_NAME" packages/*.nupkg \ + --repo "${{ github.repository }}" \ + --title "$REF_NAME" \ + --generate-notes \ + --verify-tag From 3b4cab243aa939bbeabdcac179d3d9c0a96f37b4 Mon Sep 17 00:00:00 2001 From: Jay Tuley Date: Wed, 23 Sep 2026 05:39:10 -0500 Subject: [PATCH 2/2] Review (Copilot): the release workflow's failure modes Four real ones, all about a release that cannot be undone or retried: - workflow_dispatch satisfied the job's condition while providing no workflow_run payload, so a manual run would have reached the download with an empty run id and failed every time. The trigger is gone; a failed release is re-run from its own run page. - An explicit permissions map makes every unlisted scope none, and reading another run's artifact wants actions: read. AnyUnit publishes without it, so I cannot show it is required - but it costs nothing and removes a failure I would otherwise only discover by cutting a tag. - The tag was only checked to exist. If it moves between the build and this job, the tested packages would publish under a tag pointing at a different commit. It now resolves the tag - dereferencing an annotated one - and refuses unless it matches the tested head_sha. - gh release create fails when the release already exists, so a re-run after a partial failure was not the no-op --skip-duplicate makes the pushes. It updates the assets instead when the release is there. Not changed: the claim that `dotnet nuget push 'packages/*.nupkg'` cannot take a glob. The CLI expands it itself, as master's own publish job shows ("Pushing ImpromptuInterface.8.1.0-alpha.0.14.symbols.nupkg... Your package was pushed"). The upload does move above the push, though, so a failed push to GitHub Packages cannot cost release.yml its artifact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016mwfq4oeZW8SiD4HjTHYdg --- .github/workflows/build.yml | 11 ++++---- .github/workflows/release.yml | 50 +++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d575d8a..ce0a597 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -324,13 +324,14 @@ jobs: - name: Pack run: dotnet pack ImpromptuInterface/ImpromptuInterface.csproj --configuration Release --output packages - - name: Push to GitHub Packages - run: dotnet nuget push 'packages/*.nupkg' --source https://nuget.pkg.github.com/ekonbenefits/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate - - # release.yml republishes exactly these to nuget.org when the commit is tagged, rather - # than packing again, so what ships is the artifact this run's tests passed against. + # Before the push, not after: release.yml republishes exactly these to nuget.org when the + # commit is tagged, rather than packing again, so what ships is the artifact this run's + # tests passed against - and a failed push to GitHub Packages must not cost it that. - name: Upload the packages uses: actions/upload-artifact@v4 with: name: nuget-packages path: packages/*.nupkg + + - name: Push to GitHub Packages + run: dotnet nuget push 'packages/*.nupkg' --source https://nuget.pkg.github.com/ekonbenefits/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ef62a5..614e2d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,39 +14,53 @@ # filename - so renaming this file breaks publishing until the policy is updated to match. name: release +# Not workflow_dispatch: every step here reads github.event.workflow_run, which a manual run +# does not have. Re-run this workflow from the failed run's own page instead. on: workflow_run: workflows: [build] types: [completed] - workflow_dispatch: jobs: publish: name: publish to nuget.org if: >- - github.event_name == 'workflow_dispatch' || - (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - startsWith(github.event.workflow_run.head_branch, 'v')) + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v') runs-on: ubuntu-latest permissions: id-token: write # the OIDC token Trusted Publishing exchanges for a temporary key contents: write # creating the Release + actions: read # reading the build run's artifact; an explicit map makes the rest none steps: # head_branch is the ref name for either kind of push, so a *branch* called "v-something" # would otherwise satisfy the condition above. Publishing is irreversible; check. - - name: Verify the triggering ref is a real tag + - name: Verify the ref is a tag, still pointing at the commit that was tested env: GH_TOKEN: ${{ github.token }} REF_NAME: ${{ github.event.workflow_run.head_branch }} + TESTED_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail - if ! gh api "repos/${{ github.repository }}/git/ref/tags/$REF_NAME" >/dev/null 2>&1; then + if ! tag_sha=$(gh api "repos/${{ github.repository }}/git/ref/tags/$REF_NAME" --jq '.object.sha' 2>/dev/null); then echo "::error::'$REF_NAME' is not a tag in this repository - refusing to publish." exit 1 fi - echo "Confirmed '$REF_NAME' is a tag." + + # An annotated tag's ref points at the tag object; dereference to the commit. + type=$(gh api "repos/${{ github.repository }}/git/ref/tags/$REF_NAME" --jq '.object.type') + if [ "$type" = "tag" ]; then + tag_sha=$(gh api "repos/${{ github.repository }}/git/tags/$tag_sha" --jq '.object.sha') + fi + + if [ "$tag_sha" != "$TESTED_SHA" ]; then + echo "::error::'$REF_NAME' now points at $tag_sha, but the tested build was $TESTED_SHA." + echo "The tag moved after the build; refusing to publish packages under it." + exit 1 + fi + echo "Confirmed '$REF_NAME' is a tag at the tested commit $TESTED_SHA." - uses: actions/setup-dotnet@v4 with: @@ -80,14 +94,22 @@ jobs: --skip-duplicate done - - name: Create the GitHub Release + # Idempotent, so re-running a release after a partial failure works: --skip-duplicate + # covers the pushes, and this covers the Release. + - name: Create or update the GitHub Release env: GH_TOKEN: ${{ github.token }} REF_NAME: ${{ github.event.workflow_run.head_branch }} run: | set -e - gh release create "$REF_NAME" packages/*.nupkg \ - --repo "${{ github.repository }}" \ - --title "$REF_NAME" \ - --generate-notes \ - --verify-tag + if gh release view "$REF_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Release $REF_NAME exists; refreshing its assets." + gh release upload "$REF_NAME" packages/*.nupkg \ + --repo "${{ github.repository }}" --clobber + else + gh release create "$REF_NAME" packages/*.nupkg \ + --repo "${{ github.repository }}" \ + --title "$REF_NAME" \ + --generate-notes \ + --verify-tag + fi