diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0202549..2531790 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,16 @@ on: tags: - '[0-9]+.[0-9]+.[0-9]+' - '[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+' + # Dry run: exchange the OIDC token for a nuget.org key and stop, without + # packing or publishing. Lives here rather than in its own workflow because + # the trusted publishing policy matches on the workflow filename and the + # environment — a separate workflow would fail to match even when correct. + workflow_dispatch: + inputs: + nuget_user: + description: "nuget.org profile name to exchange as (defaults to the NUGET_USER secret)" + required: false + type: string permissions: id-token: write @@ -21,10 +31,12 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Get tag + if: ${{ github.event_name == 'push' }} id: tag run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" - name: Verify tag matches package version + if: ${{ github.event_name == 'push' }} env: TAG: ${{ steps.tag.outputs.version }} run: | @@ -72,10 +84,16 @@ jobs: publish: needs: verify runs-on: ubuntu-x64 - # Must be `deployment`: NUGET_API_KEY is an environment secret scoped to it, - # and environment secrets are invisible to jobs using any other name. - # Naming a non-existent environment also silently creates an unprotected one. + # `deployment` exists and carries the required reviewers. Naming an + # environment that does not exist silently creates an unprotected one, so + # this must match a real environment and the nuget.org policy. environment: deployment + permissions: + # id-token for the nuget.org OIDC exchange; contents:write because the + # last step creates the GitHub release. The workflow default is + # contents: read, which 403s there. + id-token: write + contents: write steps: - name: Checkout @@ -102,20 +120,69 @@ jobs: # Only the library is packable. A solution-wide pack would also produce # packages for the sample projects, and the push glob below would send them. - name: Pack + if: ${{ github.event_name == 'push' }} run: dotnet pack Analytics-CSharp/Analytics-CSharp.csproj --no-restore -c Release -o artifacts - - name: Push to NuGet.org + # NuGet trusted publishing: exchange the GitHub OIDC token for an API key + # that lives one hour. Done inline rather than with NuGet/login, which is + # not on the org's allow-list. One OIDC token mints exactly one key, so + # this sits immediately before the push. + - name: Push to NuGet.org (trusted publishing) + env: + NUGET_USER: ${{ inputs.nuget_user || secrets.NUGET_USER }} run: | + set -euo pipefail + + if [ -z "${NUGET_USER:-}" ]; then + echo "::error::NUGET_USER is not set. It is the nuget.org profile name that owns the trusted publishing policy, not an email address." + exit 1 + fi + + OIDC_JWT=$(curl -sS \ + -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://www.nuget.org" \ + | jq -r '.value') + + if [ -z "$OIDC_JWT" ] || [ "$OIDC_JWT" = "null" ]; then + echo "::error::No GitHub OIDC token. The job needs 'permissions: id-token: write'." + exit 1 + fi + + RESP=$(curl -sS -X POST https://www.nuget.org/api/v2/token \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${OIDC_JWT}" \ + -d "{\"username\": \"${NUGET_USER}\", \"tokenType\": \"ApiKey\"}") + + API_KEY=$(echo "$RESP" | jq -r '.apiKey // empty') + if [ -z "$API_KEY" ]; then + echo "::error::nuget.org token exchange failed." + echo "$RESP" | jq 'del(.apiKey)' 2>/dev/null || echo "::error::(response withheld - not valid JSON)" + exit 1 + fi + echo "::add-mask::$API_KEY" + echo "Exchange succeeded as '${NUGET_USER}' — nuget.org issued a key." + + if [ "${GITHUB_EVENT_NAME}" != "push" ]; then + echo "Dry run: not packing or publishing." + exit 0 + fi + dotnet nuget push "artifacts/*.nupkg" \ --source https://api.nuget.org/v3/index.json \ - --api-key ${{ secrets.NUGET_API_KEY }} \ - --skip-duplicate + --api-key "$API_KEY" - name: Create GitHub release + if: ${{ github.event_name == 'push' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REF_NAME: ${{ github.ref_name }} run: | - gh release create "$REF_NAME" \ - --title "$REF_NAME" \ - --generate-notes + # Guarded so a re-run after a partial failure does not error with + # "release already exists". + if gh release view "$REF_NAME" >/dev/null 2>&1; then + echo "Release $REF_NAME already exists; leaving it as is." + else + gh release create "$REF_NAME" \ + --title "$REF_NAME" \ + --generate-notes + fi diff --git a/RELEASING.md b/RELEASING.md index 8516f33..e003b20 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,6 +5,12 @@ Publishing runs in CI. `deploy.yml` triggers on a pushed tag, verifies it agains the version in the source, builds, packs and pushes to NuGet.org, then creates the GitHub release. +It publishes through NuGet **trusted publishing**: the workflow exchanges a GitHub +OIDC token for an API key that lives one hour, so there is no long-lived key to +store or rotate. nuget.org matches the exchange against a policy naming the +repository, the workflow file (`deploy.yml`) and the environment (`deployment`) — +so renaming any of those breaks publishing until the policy is updated to match. + Update the version in **both** places — the deploy workflow checks both and stops if either disagrees with the tag: @@ -32,6 +38,26 @@ Release to NuGet Tag after merging, so the tag points at `main` rather than at a branch commit that may differ from what was reviewed. +Checking the credential path without releasing +============================================== + +The publish path only runs at release time, so a broken credential is normally +discovered by a failed release. To check it first, run **Deploy** manually from +the Actions tab. + +A manual run exchanges the OIDC token for a nuget.org key, reports whether that +worked, and stops — it does not pack, publish or create a release. Everything +before the exchange still runs, so it also covers Artifactory auth, the build and +the tests. + +It has to be this workflow rather than a separate one: the trusted publishing +policy matches on the workflow filename, so a different file fails to match even +when everything else is right. + +The optional `nuget_user` input overrides the `NUGET_USER` secret for that run, +which is useful when confirming which nuget.org profile the policy is registered +under. It is a profile name, never an email address. + Release to OpenUPM ==================