Release to nuget.org from a v* tag, through Trusted Publishing - #85
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mwfq4oeZW8SiD4HjTHYdg
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Critical workflow failures block tagged publishing, and moderate release-safety issues remain.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (5)
What changed in this PR
Adds tag-based publishing of tested NuGet packages to nuget.org via Trusted Publishing and GitHub Releases.
Changes:
- Builds and uploads packages for
v*tags. - Adds OIDC-authenticated NuGet publishing.
- Adds tag verification and duplicate-publish safeguards.
| File | Summary | Findings |
|---|---|---|
.github/workflows/build.yml |
Runs tagged builds and uploads package artifacts. | Critical (1 vote): The quoted package glob is passed directly to dotnet nuget push, causing tagged publishing to fail. |
.github/workflows/release.yml |
Publishes packages and creates GitHub Releases. | Critical (3 votes): Missing actions: read permission prevents artifact download.Moderate (3 votes): Manual dispatch lacks required workflow-run inputs. Moderate (3 votes): Retried runs can fail when the GitHub Release already exists. Moderate (2 votes): Tag ownership is not verified against the tested commit. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if: >- | ||
| (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) | ||
| && github.event_name != 'pull_request' |
| permissions: | ||
| id-token: write # the OIDC token Trusted Publishing exchanges for a temporary key | ||
| contents: write # creating the Release |
| 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')) |
| 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." |
| gh release create "$REF_NAME" packages/*.nupkg \ | ||
| --repo "${{ github.repository }}" \ | ||
| --title "$REF_NAME" \ | ||
| --generate-notes \ | ||
| --verify-tag |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mwfq4oeZW8SiD4HjTHYdg
|
Four of the five are real and are fixed; the fifth is wrong on its premise, with evidence. Manual dispatch could never work. Correct —
A tag that moves between build and publish. The best of the five. The check only confirmed the tag existed, so a force-moved tag would have published the tested packages under a different commit. It now resolves the tag — dereferencing an annotated tag to its commit — and refuses unless it equals A re-run after a partial failure. Also correct: The glob, though, is not a bug. That step has been succeeding on every master push. The ordering point underneath it is worth taking anyway: the artifact upload now happens before the push, so a failure pushing to GitHub Packages cannot leave |


Adds the missing half of releasing: today a master push publishes a prerelease to GitHub Packages, and there is no path to nuget.org at all.
What you need to do on nuget.org first
This cannot work until a Trusted Publishing policy exists. On nuget.org, under the EkonBenefits account (the package is co-owned by
EkonBenefitsandjbtule; the org account is the one that survives a handover):ImpromptuInterfaceekonbenefits, repositoryimpromptu-interfacerelease.yml— the policy is keyed to the filename, so renaming this file breaks publishing until the policy is updatedThen one repository secret,
NUGET_USER=EkonBenefits. That is an account name, not a credential; there is no API key to store or rotate.Worth confirming in the UI that an organization account can hold a policy at all — I have not verified that, and if it cannot,
jbtuleis the fallback and nothing else here changes.How it works
NuGet/login@v1exchanges the workflow's OIDC token (permissions: id-token: write) for a temporary API key, anddotnet nuget pushuses that. Same mechanism asjbtule/AnyUnit.It republishes what was tested. Rather than packing a second time, it downloads the
nuget-packagesartifact from thebuildrun for that commit — so what reaches nuget.org is the artifact those tests passed against, not a separately produced and almost-certainly-identical set that never went through them. That is why it triggers offbuildcompleting rather than off the tag push directly, and whybuild.ymlnow also runs onv*tags and uploads its packages.A
workflow_runtrigger is always read from the default branch, whatever the tag contains, so this file has to be on master before it will fire for any future tag.Guards, because publishing is irreversible
head_branchis the ref name for either kind of push, so a branch namedv-somethingwould otherwise satisfy the trigger condition. The first step asks the API whether the ref is a real tag and fails if not.--verify-tag.--skip-duplicate, so re-running a release is a no-op rather than an error.Cutting 8.1.0, once the policy exists
MinVer takes the version from the tag,
buildruns the full matrix, and on successreleasepublishes and creates the Release.MinVerMinimumMajorMinorinDirectory.Build.propsthen wants raising to the next intended release, or untagged builds will start claiming to be heading for 8.1.1.🤖 Generated with Claude Code
https://claude.ai/code/session_016mwfq4oeZW8SiD4HjTHYdg