Skip to content

Commit 97a8405

Browse files
authored
Publish to NuGet via trusted publishing, fix release permissions (#153)
* feat(ci): publish to NuGet via trusted publishing, fix release permissions 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. * feat(ci): add a dry-run dispatch to test the nuget.org exchange 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. * fix(ci): gate the release steps on the event, not the 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. * docs: describe trusted publishing and the dry run 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.
1 parent b996cf0 commit 97a8405

2 files changed

Lines changed: 102 additions & 9 deletions

File tree

‎.github/workflows/deploy.yml‎

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ on:
55
tags:
66
- '[0-9]+.[0-9]+.[0-9]+'
77
- '[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+'
8+
# Dry run: exchange the OIDC token for a nuget.org key and stop, without
9+
# packing or publishing. Lives here rather than in its own workflow because
10+
# the trusted publishing policy matches on the workflow filename and the
11+
# environment — a separate workflow would fail to match even when correct.
12+
workflow_dispatch:
13+
inputs:
14+
nuget_user:
15+
description: "nuget.org profile name to exchange as (defaults to the NUGET_USER secret)"
16+
required: false
17+
type: string
818

919
permissions:
1020
id-token: write
@@ -21,10 +31,12 @@ jobs:
2131
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
2232

2333
- name: Get tag
34+
if: ${{ github.event_name == 'push' }}
2435
id: tag
2536
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
2637

2738
- name: Verify tag matches package version
39+
if: ${{ github.event_name == 'push' }}
2840
env:
2941
TAG: ${{ steps.tag.outputs.version }}
3042
run: |
@@ -72,10 +84,16 @@ jobs:
7284
publish:
7385
needs: verify
7486
runs-on: ubuntu-x64
75-
# Must be `deployment`: NUGET_API_KEY is an environment secret scoped to it,
76-
# and environment secrets are invisible to jobs using any other name.
77-
# Naming a non-existent environment also silently creates an unprotected one.
87+
# `deployment` exists and carries the required reviewers. Naming an
88+
# environment that does not exist silently creates an unprotected one, so
89+
# this must match a real environment and the nuget.org policy.
7890
environment: deployment
91+
permissions:
92+
# id-token for the nuget.org OIDC exchange; contents:write because the
93+
# last step creates the GitHub release. The workflow default is
94+
# contents: read, which 403s there.
95+
id-token: write
96+
contents: write
7997

8098
steps:
8199
- name: Checkout
@@ -102,20 +120,69 @@ jobs:
102120
# Only the library is packable. A solution-wide pack would also produce
103121
# packages for the sample projects, and the push glob below would send them.
104122
- name: Pack
123+
if: ${{ github.event_name == 'push' }}
105124
run: dotnet pack Analytics-CSharp/Analytics-CSharp.csproj --no-restore -c Release -o artifacts
106125

107-
- name: Push to NuGet.org
126+
# NuGet trusted publishing: exchange the GitHub OIDC token for an API key
127+
# that lives one hour. Done inline rather than with NuGet/login, which is
128+
# not on the org's allow-list. One OIDC token mints exactly one key, so
129+
# this sits immediately before the push.
130+
- name: Push to NuGet.org (trusted publishing)
131+
env:
132+
NUGET_USER: ${{ inputs.nuget_user || secrets.NUGET_USER }}
108133
run: |
134+
set -euo pipefail
135+
136+
if [ -z "${NUGET_USER:-}" ]; then
137+
echo "::error::NUGET_USER is not set. It is the nuget.org profile name that owns the trusted publishing policy, not an email address."
138+
exit 1
139+
fi
140+
141+
OIDC_JWT=$(curl -sS \
142+
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
143+
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://www.nuget.org" \
144+
| jq -r '.value')
145+
146+
if [ -z "$OIDC_JWT" ] || [ "$OIDC_JWT" = "null" ]; then
147+
echo "::error::No GitHub OIDC token. The job needs 'permissions: id-token: write'."
148+
exit 1
149+
fi
150+
151+
RESP=$(curl -sS -X POST https://www.nuget.org/api/v2/token \
152+
-H "Content-Type: application/json" \
153+
-H "Authorization: Bearer ${OIDC_JWT}" \
154+
-d "{\"username\": \"${NUGET_USER}\", \"tokenType\": \"ApiKey\"}")
155+
156+
API_KEY=$(echo "$RESP" | jq -r '.apiKey // empty')
157+
if [ -z "$API_KEY" ]; then
158+
echo "::error::nuget.org token exchange failed."
159+
echo "$RESP" | jq 'del(.apiKey)' 2>/dev/null || echo "::error::(response withheld - not valid JSON)"
160+
exit 1
161+
fi
162+
echo "::add-mask::$API_KEY"
163+
echo "Exchange succeeded as '${NUGET_USER}' — nuget.org issued a key."
164+
165+
if [ "${GITHUB_EVENT_NAME}" != "push" ]; then
166+
echo "Dry run: not packing or publishing."
167+
exit 0
168+
fi
169+
109170
dotnet nuget push "artifacts/*.nupkg" \
110171
--source https://api.nuget.org/v3/index.json \
111-
--api-key ${{ secrets.NUGET_API_KEY }} \
112-
--skip-duplicate
172+
--api-key "$API_KEY"
113173
114174
- name: Create GitHub release
175+
if: ${{ github.event_name == 'push' }}
115176
env:
116177
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
117178
REF_NAME: ${{ github.ref_name }}
118179
run: |
119-
gh release create "$REF_NAME" \
120-
--title "$REF_NAME" \
121-
--generate-notes
180+
# Guarded so a re-run after a partial failure does not error with
181+
# "release already exists".
182+
if gh release view "$REF_NAME" >/dev/null 2>&1; then
183+
echo "Release $REF_NAME already exists; leaving it as is."
184+
else
185+
gh release create "$REF_NAME" \
186+
--title "$REF_NAME" \
187+
--generate-notes
188+
fi

‎RELEASING.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ Publishing runs in CI. `deploy.yml` triggers on a pushed tag, verifies it agains
55
the version in the source, builds, packs and pushes to NuGet.org, then creates the
66
GitHub release.
77

8+
It publishes through NuGet **trusted publishing**: the workflow exchanges a GitHub
9+
OIDC token for an API key that lives one hour, so there is no long-lived key to
10+
store or rotate. nuget.org matches the exchange against a policy naming the
11+
repository, the workflow file (`deploy.yml`) and the environment (`deployment`) —
12+
so renaming any of those breaks publishing until the policy is updated to match.
13+
814
Update the version in **both** places — the deploy workflow checks both and stops
915
if either disagrees with the tag:
1016

@@ -32,6 +38,26 @@ Release to NuGet
3238
Tag after merging, so the tag points at `main` rather than at a branch commit
3339
that may differ from what was reviewed.
3440

41+
Checking the credential path without releasing
42+
==============================================
43+
44+
The publish path only runs at release time, so a broken credential is normally
45+
discovered by a failed release. To check it first, run **Deploy** manually from
46+
the Actions tab.
47+
48+
A manual run exchanges the OIDC token for a nuget.org key, reports whether that
49+
worked, and stops — it does not pack, publish or create a release. Everything
50+
before the exchange still runs, so it also covers Artifactory auth, the build and
51+
the tests.
52+
53+
It has to be this workflow rather than a separate one: the trusted publishing
54+
policy matches on the workflow filename, so a different file fails to match even
55+
when everything else is right.
56+
57+
The optional `nuget_user` input overrides the `NUGET_USER` secret for that run,
58+
which is useful when confirming which nuget.org profile the policy is registered
59+
under. It is a profile name, never an email address.
60+
3561
Release to OpenUPM
3662
==================
3763

0 commit comments

Comments
 (0)