Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 76 additions & 9 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down Expand Up @@ -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
Expand All @@ -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
26 changes: 26 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
==================

Expand Down
Loading