Skip to content

Release to nuget.org from a v* tag, through Trusted Publishing - #85

Merged
jbtule merged 2 commits into
masterfrom
release-workflow
Sep 23, 2026
Merged

jbtule merged 2 commits into
masterfrom
release-workflow

Conversation

@jbtule

@jbtule jbtule commented Sep 23, 2026

Copy link
Copy Markdown
Member

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 EkonBenefits and jbtule; the org account is the one that survives a handover):

  • package ImpromptuInterface
  • repository owner ekonbenefits, repository impromptu-interface
  • workflow file release.yml — the policy is keyed to the filename, so renaming this file breaks publishing until the policy is updated

Then 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, jbtule is the fallback and nothing else here changes.

How it works

NuGet/login@v1 exchanges the workflow's OIDC token (permissions: id-token: write) for a temporary API key, and dotnet nuget push uses that. Same mechanism as jbtule/AnyUnit.

It republishes what was tested. Rather than packing a second time, it downloads the nuget-packages artifact from the build run 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 off build completing rather than off the tag push directly, and why build.yml now also runs on v* tags and uploads its packages.

A workflow_run trigger 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_branch is the ref name for either kind of push, so a branch named v-something would otherwise satisfy the trigger condition. The first step asks the API whether the ref is a real tag and fails if not.
  • The GitHub Release is created with --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

git tag -a v8.1.0 -m "ImpromptuInterface 8.1.0"
git push origin v8.1.0

MinVer takes the version from the tag, build runs the full matrix, and on success release publishes and creates the Release. MinVerMinimumMajorMinor in Directory.Build.props then 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

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
Copilot AI lite review requested due to automatic review settings September 23, 2026 03:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity · 3 Medium severity

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.

Comment on lines +308 to +310
if: >-
(github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
&& github.event_name != 'pull_request'
Comment on lines +32 to +34
permissions:
id-token: write # the OIDC token Trusted Publishing exchanges for a temporary key
contents: write # creating the Release
Comment thread .github/workflows/release.yml Outdated
Comment on lines +27 to +30
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'))
Comment thread .github/workflows/release.yml Outdated
Comment on lines +45 to +49
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."
Comment thread .github/workflows/release.yml Outdated
Comment on lines +89 to +93
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
@jbtule

jbtule commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

Four of the five are real and are fixed; the fifth is wrong on its premise, with evidence.

Manual dispatch could never work. Correct — workflow_dispatch satisfied the condition while providing no workflow_run payload, so the download would get an empty run id every time. The trigger is removed rather than given inputs: a failed release is re-run from its own run page, which keeps the original event.

actions: read. Added. Being straight about the evidence: jbtule/AnyUnit publishes the same way with only id-token: write and contents: write, and its cross-run download succeeds — so I cannot show it is required here. It costs nothing and removes a failure I would otherwise only find by cutting a tag.

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 workflow_run.head_sha.

A re-run after a partial failure. Also correct: --skip-duplicate covers the pushes, but gh release create fails when the release exists, so the rerun-safety I claimed was only half true. It now updates the assets when the release is already there.

The glob, though, is not a bug. dotnet nuget push expands the pattern itself — it is the CLI, not the shell. From master's own publish job on this repo:

Run dotnet nuget push 'packages/*.nupkg' --source https://nuget.pkg.github.com/ekonbenefits/index.json …
Pushing ImpromptuInterface.8.1.0-alpha.0.14.symbols.nupkg to 'https://nuget.pkg.github.com/ekonbenefits'...
Your package was pushed.

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 release.yml with no artifact to publish.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants