From 7fb76c202a22b056c789ad35ad0872c238fc8fa1 Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Sat, 1 Aug 2026 13:23:51 +0300 Subject: [PATCH] ci: publish to NuGet with trusted publishing instead of a stored API key GitHub mints a short-lived OIDC token, nuget.org verifies it against a policy naming this exact repository, workflow file and environment, and returns a key valid for one hour. Nothing long-lived is stored in the repository, so there is no key to leak, rotate, or scope too widely -- and a leaked key cannot be replayed from anywhere else, because the token only validates from this workflow. The exchange happens immediately before the push: the temporary key lasts an hour and each OIDC token buys exactly one key, so requesting it early enough to expire is a real failure mode. NuGet/login is pinned to a commit SHA like every other action here. Note that the v1 tag is annotated, so the tag object SHA is not the commit SHA -- pinned to the commit, 8d196754, which v1.2.0 also points at. RELEASING.md now walks through the three prerequisites step by step: the policy fields on nuget.org (workflow file name only, no path), the NUGET_USER secret holding the profile name rather than an email, and the environment whose required reviewer is both the approval gate and what the policy binds to. --- .github/workflows/release.yml | 24 +++++++++++---- RELEASING.md | 56 ++++++++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa22fc0..266a93f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,6 +117,9 @@ jobs: environment: nuget permissions: contents: write + # Lets GitHub mint the short-lived OIDC token that nuget.org exchanges for a temporary + # API key. This is what removes the long-lived secret from the repository entirely. + id-token: write steps: - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -126,16 +129,27 @@ jobs: name: release-artifacts path: artifacts - - name: Push to NuGet + - name: Fail early if trusted publishing is not configured env: - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + NUGET_USER: ${{ secrets.NUGET_USER }} run: | - if [ -z "$NUGET_API_KEY" ]; then - echo "::error::NUGET_API_KEY is not configured; refusing to publish." + if [ -z "$NUGET_USER" ]; then + echo "::error::NUGET_USER is not set. See RELEASING.md — trusted publishing needs the nuget.org profile name and a matching policy on nuget.org." exit 1 fi + + # Exchanged immediately before the push: the temporary key nuget.org issues lasts one hour, + # and each OIDC token buys exactly one key. + - name: Exchange the OIDC token for a temporary NuGet key + id: login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Push to NuGet + run: | dotnet nuget push "artifacts/*.nupkg" \ - --api-key "$NUGET_API_KEY" \ + --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \ --source https://api.nuget.org/v3/index.json \ --skip-duplicate diff --git a/RELEASING.md b/RELEASING.md index abc5909..cf2cbf1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -56,24 +56,60 @@ release is cheap. ### Prerequisites, once -> **Neither of these is configured yet.** As of the last check the repository has no environments -> and no secrets. This matters more than it looks: a workflow that names an environment which does -> not exist does **not** fail — GitHub creates it implicitly, with no protection rules. So until -> the steps below are done there is no approval gate, and the only thing standing between a -> mistyped manual run and a permanent package is the version guard in the workflow itself. +> **None of this is configured yet.** As of the last check the repository has no environments and +> no secrets. This matters more than it looks: a workflow that names an environment which does not +> exist does **not** fail — GitHub creates it implicitly, with no protection rules. So until the +> steps below are done there is no approval gate, and the only thing standing between a mistyped +> manual run and a permanent package is the version guard in the workflow itself. -- `NUGET_API_KEY` as a repository secret, scoped to `LakeSpeak.*`, not a global key. -- A `nuget` **environment** in repository settings, **with a required reviewer**. Creating the - environment alone changes nothing; the required reviewer is the gate. This is what turns - publishing into a decision someone makes rather than a side effect of pushing a tag. +Publishing uses **trusted publishing**, not a stored API key. GitHub mints a short-lived OIDC +token, nuget.org verifies it against a policy naming this exact repository and workflow, and +returns a temporary key valid for one hour. Nothing long-lived is ever stored in the repository, +so there is no key to leak, rotate, or accidentally scope too widely. -Verify both are in place before the first release: +**1. Create the trusted publishing policy on nuget.org.** + +Log in to nuget.org → your username → **Trusted Publishing** → add a policy: + +| Field | Value | +|---|---| +| Repository Owner | `ivanvyd` | +| Repository | `lakespeak` | +| Workflow File | `release.yml` — the file name only, **not** `.github/workflows/release.yml` | +| Environment | `nuget` — must match the `environment:` in the publish job | + +The policy is owned by you or by an organisation, and applies to every package that owner owns. +If Trusted Publishing does not appear in your account, it has not been rolled out to you yet; +in that case fall back to an API key scoped to `LakeSpeak.*`. + +**2. Add the `NUGET_USER` repository secret.** + +Your nuget.org **profile name** — not your email address. It is not a credential; it is a secret +only so the workflow file does not hard-code an account name. + +```bash +gh secret set NUGET_USER +``` + +**3. Create the `nuget` environment with a required reviewer.** + +Settings → Environments → New environment → `nuget` → add yourself under *Required reviewers*. + +Creating the environment alone changes nothing — the required reviewer *is* the gate, and it is +also what the trusted publishing policy binds to. Without it, publishing is a side effect of +pushing a tag rather than a decision someone makes. + +**4. Verify all three before the first release.** ```bash gh api repos/ivanvyd/lakespeak/environments --jq '.environments[] | {name, rules: [.protection_rules[].type]}' gh secret list ``` +The first publish also completes the policy: for a new policy nuget.org records the GitHub +repository and owner IDs on first successful use, which is what stops someone deleting the repo, +recreating it under the same name, and publishing as if nothing changed. + ### Steps 1. Update `CHANGELOG.md`. Move `Unreleased` entries under a new `## ` heading.