From bf75ae5f1a26c9b5bab2aea7715f6b0d4de6f237 Mon Sep 17 00:00:00 2001 From: Thanikan Date: Fri, 9 Oct 2026 21:15:37 +0530 Subject: [PATCH] ci(release): stage packages on npm for maintainer approval release.yml now runs `npm stage publish` instead of `npm publish`. The tarball goes to npm's staging queue and is not installable until a maintainer approves it on npm with 2FA. With the package's trusted publisher limited to `npm stage publish`, CI can no longer make a version live on its own, even if the workflow or an action in it is tampered with. - release.yml: stage instead of publish (the dry run too), assert npm >= 11.15.0 (what `npm stage` needs), and write the stage output and the approve commands to the job summary. - version-bump.yml and README: describe the extra "approve on npm" step. - CONTRIBUTING.md: add "Approving a staged release" and the npm settings a package needs (stage-only trusted publisher, 2FA required, tokens disallowed). --- .github/workflows/release.yml | 89 ++++++++++++++++++++++-------- .github/workflows/version-bump.yml | 8 ++- CONTRIBUTING.md | 29 ++++++++++ README.md | 5 +- 4 files changed, 104 insertions(+), 27 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfb2d2c..14820b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,15 @@ -name: Release - Publish @opennsw package +name: Release - Stage @opennsw package on npm -# Publishes any package under packages/ to the npm registry when a release tag -# is pushed. The package is derived from the tag, so one workflow serves every +# Stages any package under packages/ on the npm registry when a release tag is +# pushed. The package is derived from the tag, so one workflow serves every # package in the monorepo. # +# This workflow can only STAGE. It runs `npm stage publish`, which uploads the +# tarball to npm's staging queue; the version is not installable until a +# maintainer approves it on npm with 2FA (see "Approving a staged release" in +# CONTRIBUTING.md). The package's trusted publisher on npmjs.com is limited to +# `npm stage publish`, so even a changed workflow cannot publish directly. +# # Tag convention: -v # - is the folder name under packages/ # - must match the "version" in that package's package.json @@ -14,27 +20,30 @@ name: Release - Publish @opennsw package # git tag jsonforms-renderers-v0.3.1 # git push origin jsonforms-renderers-v0.3.1 # 3. This workflow resolves the package from the tag, verifies the version, -# builds, and publishes. +# builds, and stages the package on npm. +# 4. A maintainer approves the staged package on npm (2FA). Only then is it live. # # Authentication: npm Trusted Publishing via GitHub OIDC — no NPM_TOKEN secret. # One-time setup per package on npmjs.com: open the package's Settings -> -# Trusted Publisher and register GitHub Actions with this repo and workflow file -# `release.yml`. `id-token: write` + a recent npm CLI also attach build -# provenance (the verified badge on npmjs.com). +# Trusted Publisher, register GitHub Actions with this repo and workflow file +# `release.yml`, and under "Allowed actions" keep only `npm stage publish`. Also +# require two-factor authentication and disallow tokens in the package's +# publishing access, so there is no other way to publish. `id-token: write` + a +# recent npm CLI also attach build provenance (the verified badge on npmjs.com). on: push: tags: - '*-v*.*.*' -# Don't run two publishes of the same tag concurrently. +# Don't run two stagings of the same tag concurrently. concurrency: group: release-${{ github.ref_name }} cancel-in-progress: false jobs: publish: - name: Build and publish to npm + name: Build and stage on npm runs-on: ubuntu-latest permissions: contents: read @@ -56,12 +65,19 @@ jobs: cache: 'pnpm' cache-dependency-path: './pnpm-lock.yaml' - # Trusted Publishing / OIDC requires npm CLI >= 11.5.1. The npm bundled - # with Node 22.18 is older, so upgrade it before publishing. Pinned to - # the 11.x line (rather than @latest) because npm 12 requires Node - # >=22.22.2, newer than the Node 22.18.0 pinned above. - - name: Upgrade npm (OIDC trusted publishing support) - run: npm install --global npm@11 + # Trusted Publishing / OIDC requires npm CLI >= 11.5.1, and `npm stage` + # requires >= 11.15.0. The npm bundled with Node 22.18 is older, so upgrade + # it before staging. Pinned to the 11.x line (rather than @latest) because + # npm 12 requires Node >=22.22.2, newer than the Node 22.18.0 pinned above. + - name: Upgrade npm (trusted and staged publishing support) + run: | + npm install --global npm@11 + required=11.15.0 + installed="$(npm --version)" + if [ "$(printf '%s\n%s\n' "$required" "$installed" | sort -V | head -n1)" != "$required" ]; then + echo "::error::npm $installed is older than $required, which 'npm stage publish' needs." + exit 1 + fi - name: Install dependencies run: pnpm install --frozen-lockfile @@ -94,8 +110,11 @@ jobs: echo "::error::Tag version ($TAG_VERSION) does not match $PKG_PATH/package.json ($PKG_VERSION)." exit 1 fi - echo "path=$PKG_PATH" >> "$GITHUB_OUTPUT" - echo "name=$PKG_NAME" >> "$GITHUB_OUTPUT" + { + echo "path=$PKG_PATH" + echo "name=$PKG_NAME" + echo "version=$PKG_VERSION" + } >> "$GITHUB_OUTPUT" - name: Type check run: pnpm --filter "${{ steps.pkg.outputs.name }}" run type-check @@ -103,11 +122,37 @@ jobs: - name: Build package run: pnpm --filter "${{ steps.pkg.outputs.name }}" run build - # Sanity-check the publishable tarball (no real upload). - - name: Publish dry run + # Sanity-check the tarball (no real upload). + - name: Stage dry run working-directory: ${{ steps.pkg.outputs.path }} - run: npm publish --provenance --access public --dry-run + run: npm stage publish --provenance --access public --dry-run - - name: Publish to npm + # Uploads the tarball to npm's staging queue. Nothing is installable until a + # maintainer approves it on npm, so a green run here does NOT mean the + # version is live. The output (it carries the stage ID) goes to the job + # summary, with the commands the approver needs. + - name: Stage on npm working-directory: ${{ steps.pkg.outputs.path }} - run: npm publish --provenance --access public + env: + PKG_NAME: ${{ steps.pkg.outputs.name }} + PKG_VERSION: ${{ steps.pkg.outputs.version }} + run: | + set -o pipefail + npm stage publish --provenance --access public 2>&1 | tee "$RUNNER_TEMP/stage.log" + { + echo "### Staged $PKG_NAME@$PKG_VERSION on npm" + echo + echo "**Not live yet.** A maintainer must approve it with 2FA: on npmjs.com (Staged Packages tab) or with" + echo + echo '```sh' + echo "npm stage list $PKG_NAME" + echo "npm stage download # inspect the tarball first" + echo "npm stage approve " + echo '```' + echo + echo "Output of \`npm stage publish\`:" + echo + echo '```' + cat "$RUNNER_TEMP/stage.log" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index dfcc1fc..f920d09 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -11,7 +11,9 @@ name: Release - Bump package version # 3. Tag the merge commit on main and push the tag, e.g.: # git tag jsonforms-renderers-v0.4.1 # git push origin jsonforms-renderers-v0.4.1 -# That tag push triggers release.yml, which builds and publishes to npm. +# That tag push triggers release.yml, which builds and stages the package on +# npm. +# 4. A maintainer approves the staged package on npm (2FA). Only then is it live. # # Note: this workflow pushes/opens its PR using the default GITHUB_TOKEN, so # packages-ci.yml will NOT run on the PR it creates (GitHub doesn't let the @@ -108,14 +110,14 @@ jobs: --body "$(cat < # find the stage ID +npm stage download # optional: inspect the tarball +npm stage approve # asks for 2FA; the version goes live +npm stage reject # discard it instead +``` + +Or use the **Staged Packages** tab of the package on npmjs.com. Look at the tarball before approving: it should contain `dist`, `README.md` and `package.json`, and the code should match the tag. Prefer an approver other than whoever pushed the tag; npm doesn't enforce that. Check afterwards that the approved version carries its provenance badge. + +Don't re-run a release while its version is still staged: a re-run would stage it again. Reject the old stage first. + +### npm settings for a package + +Set these on npmjs.com, in the package's settings, before its first staged release: + +- **Trusted Publisher:** GitHub Actions, repository `OpenNSW/ui-packages`, workflow `release.yml`, with **Allowed actions** set to `npm stage publish` only (not `npm publish`), so CI can stage a version but never make it live. `release.yml` can't stage the package until this is done. +- **Publishing access:** require two-factor authentication and disallow tokens, so nothing else can publish. diff --git a/README.md b/README.md index 2e4d34b..1b1fea5 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ pnpm --filter @opennsw/jsonforms-renderers run dev ## Releasing -Each package is published independently via its own tag-triggered workflow using npm [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC, no token secret). For `@opennsw/jsonforms-renderers`: +Each package is released independently via its own tag-triggered workflow, using npm [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC, no token secret) and [staged publishing](https://docs.npmjs.com/staged-publishing/): CI stages the version, and a maintainer approves it on npm with 2FA before it is installable. For `@opennsw/jsonforms-renderers`: 1. Bump `"version"` in `packages/jsonforms-renderers/package.json` and merge to `main`. 2. Tag the commit and push the tag: @@ -76,7 +76,8 @@ Each package is published independently via its own tag-triggered workflow using git tag jsonforms-renderers-v0.3.1 git push origin jsonforms-renderers-v0.3.1 ``` -3. The release workflow verifies the tag matches `package.json`, builds, and publishes to npm with provenance. +3. The release workflow verifies the tag matches `package.json`, builds, and stages the package on npm with provenance. +4. A maintainer approves the staged package on npm; only then is it live. See [Approving a staged release](CONTRIBUTING.md#approving-a-staged-release). ## License