Skip to content

Publish to NuGet via trusted publishing, fix release permissions - #153

Merged
MichaelGHSeg merged 4 commits into
mainfrom
ci/nuget-trusted-publishing
Sep 30, 2026
Merged

MichaelGHSeg merged 4 commits into
mainfrom
ci/nuget-trusted-publishing

Conversation

@MichaelGHSeg

Copy link
Copy Markdown
Contributor

Addresses the 403 that blocked 3.0.0 and two issues from @didiergarcia's review of #150.

Trusted publishing instead of a stored key

The 3.0.0 push failed with 403 (The specified API key is invalid, has expired, or does not have permission…). NUGET_API_KEY was last rotated 2025-03-04; nuget.org keys cap at 365 days. Rotating it restarts the same clock, so this moves to trusted publishing — the GitHub OIDC token is exchanged for a key that lives one hour.

Done inline rather than with NuGet/login@v1, which is not on the segmentio allow-list and would fail at workflow startup. Same approach as analytics-ruby, where the inline RubyGems exchange has now published successfully. The endpoint and payload are the ones Microsoft documents for the GitLab path:

POST https://www.nuget.org/api/v2/token
  Authorization: Bearer <OIDC token>          # audience https://www.nuget.org
  {"username": "<nuget profile>", "tokenType": "ApiKey"}

The blocker nobody reached

The publish job inherited contents: read from the workflow, but its final step runs gh release create, which needs contents: write. Both 3.0.0 attempts died at the push first, so it never surfaced — the moment the credential worked, the package would have published and then the release step would 403, leaving NuGet published with no GitHub release. The deleted release.yml carried permissions: write-all, which is why 2.6.0's release exists; removing it took the write permission with it.

The job now declares id-token: write and contents: write explicitly.

Smaller fixes

  • gh release create is guarded by gh release view, so a re-run after a partial failure no longer errors with "release already exists".
  • --skip-duplicate dropped. It made re-tagging an already-published version report success while publishing nothing — which nearly happened here.

Before this can release

Two things outside the repo:

  1. A trusted publishing policy on nuget.org — Repository Owner segmentio, Repository Analytics-CSharp, Workflow File deploy.yml (filename only), Environment deployment. It can be owned by the Segment org, which already owns the package.
  2. A NUGET_USER secret in the deployment environment — the nuget.org profile name, not an email address.

The step fails with a clear message if NUGET_USER is missing rather than producing an opaque exchange error.

Re-tagging is required

deploy.yml runs from the tagged commit, so 3.0.0 has to be deleted and re-pushed after this merges. A re-run of the existing run would replay the old workflow file. (Rotating a secret would survive a re-run; a workflow change does not.)

Not addressed here

From the same review, left for separate PRs: deleting build.yml, pinning or deleting publish-e2e-cli.yml, restoring E2E_TESTS_TOKEN for e2e-tests.yml, and TestVersion in Tests/AnalyticsTest.cs being a tautology (_analytics.Version returns Version.SegmentVersion) that cannot detect the drift #150 exists to prevent.

…sions

Exchanges the GitHub OIDC token for a nuget.org API key that lives one hour,
so no long-lived key is stored. The expired NUGET_API_KEY is what blocked
3.0.0; this removes the class of failure rather than restarting its clock.

Done inline rather than with NuGet/login, which is not on the org allow-list
and would fail at startup. One OIDC token mints exactly one key, so the
exchange sits immediately before the push.

Two fixes Didier found in review:

- The job inherited contents: read from the workflow, but its last step runs
  gh release create, which needs contents: write. Neither 3.0.0 attempt reached
  it — both died at the push — so it would have surfaced as a package published
  to NuGet with no GitHub release. The deleted release.yml carried write-all,
  which is why 2.6.0's release exists.
- gh release create had no existence guard, so a re-run after a partial failure
  errored with "release already exists".

--skip-duplicate is dropped too: it made a re-tag of an already-published
version report success while publishing nothing.
Trusted publishing matches on the workflow filename and environment, so the
exchange can only be tested from deploy.yml itself — a separate workflow would
fail to match even when everything is correct.

workflow_dispatch runs the exchange and stops before packing or publishing, and
takes an optional nuget_user input so the profile name can be tried without
editing the workflow. Tag-only steps are gated on github.ref_type.
workflow_dispatch can be run against a tag, where ref_type is 'tag' but the
event is not a push. Pack and the GitHub release were gated on ref_type while
the push was gated on the event, so a dry run against a tag would have created
a release. Tags are the only push trigger, so one rule covers both.
Records that publishing is keyless and that the nuget.org policy matches on
repository, workflow filename and environment — renaming any of them breaks
publishing silently.

Adds the manual Deploy run as a way to check the credential path without
cutting a release, which is the gap that let an expired key sit unnoticed from
March until a release needed it.
@MichaelGHSeg
MichaelGHSeg merged commit 97a8405 into main Sep 30, 2026
10 of 11 checks passed
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