From 41e11bdf3390ac15930944c2705d1ca91ba2729b Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Thu, 24 Sep 2026 00:12:06 -0600 Subject: [PATCH] Stage npm releases from GitHub Actions Publishing a GitHub release whose tag equals package.json's version runs the new Publish workflow. It builds, tests and packs, then stages the tarball on npm through OIDC trusted publishing, with provenance. Nothing goes live until an npm maintainer approves it with 2FA. Bumps the version to 0.5.5 for the first release through this path. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 129 ++++++++++++++++++++++++++++++++++ README.md | 8 +++ package.json | 2 +- 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6175de4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,129 @@ +name: Publish + +# Stages a release on npm; a maintainer then approves it on npmjs.com with 2FA. +# npm authenticates this workflow by OIDC (trusted publishing), so there is no +# npm token anywhere. The trusted publisher on npmjs.com is bound to this file +# name and the `npm` environment, and only allows `npm stage publish`. +on: + release: + types: [published] + +concurrency: + group: publish + cancel-in-progress: false + +permissions: + contents: read + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + dist-tag: ${{ steps.release.outputs.dist-tag }} + tarball: ${{ steps.pack.outputs.tarball }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # Full history, so the tagged commit can be checked against master. + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version-file: .nvmrc + package-manager-cache: false + + - name: Enable Corepack + run: corepack enable + + - name: Check release + id: release + env: + TAG: ${{ github.event.release.tag_name }} + run: | + version=$(node -p 'require("./package.json").version') + if [ "$TAG" != "$version" ]; then + echo "::error::Release tag '$TAG' does not match package.json version '$version'" + exit 1 + fi + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then + echo "::error::Release tag '$TAG' points at a commit that is not on master" + exit 1 + fi + # A prerelease version must never become `latest`. + if [[ "$version" == *-* ]]; then + echo "dist-tag=next" >> "$GITHUB_OUTPUT" + else + echo "dist-tag=latest" >> "$GITHUB_OUTPUT" + fi + + - name: Install dependencies + # No dependency needs an install script; refusing them all keeps a + # compromised package from running code at install time. + run: yarn install --frozen-lockfile --ignore-scripts + + - name: Test + # Runs mocha under c8 coverage, then eslint. + run: yarn ci-test + + - name: Pack + id: pack + run: | + tarball=$(npm pack --ignore-scripts --json | node -p 'JSON.parse(require("fs").readFileSync(0))[0].filename') + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" + + - name: Upload package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: package + path: ${{ steps.pack.outputs.tarball }} + if-no-files-found: error + retention-days: 30 + + stage: + name: Stage on npm + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + # Bound in the npm trusted publisher config, and limited to release tags. + environment: + name: npm + url: https://www.npmjs.com/package/prolly + # The only job that can mint an npm OIDC token. It never checks out or + # installs anything; it stages the tarball the build job produced. + permissions: + id-token: write + steps: + - name: Download package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: package + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + # Staged publishing needs npm >= 11.15.0, which Node 24 bundles. + node-version: 24 + package-manager-cache: false + + - name: Stage + env: + TARBALL: ${{ needs.build.outputs.tarball }} + DIST_TAG: ${{ needs.build.outputs.dist-tag }} + run: | + npm stage publish "./$TARBALL" --tag "$DIST_TAG" --provenance + { + echo "### Staged \`$TARBALL\` on npm with dist-tag \`$DIST_TAG\`" + echo + echo "Nothing is live yet. A package maintainer must approve it with 2FA," + echo "either under Staged Packages on npmjs.com or with:" + echo + echo '```sh' + echo "npm stage list prolly" + echo "npm stage approve " + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index c710e43..fd264aa 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,14 @@ Prolly.poll(isReady, 5000, validateFn, 1000, 10) 4. Open PR * (Optional) link to your development soundtrack +## Releasing + +Maintainers only. Releases are staged on npm by the [Publish](.github/workflows/publish.yml) workflow and go live only once an npm maintainer approves them with 2FA. + +1. Open a PR that sets `version` in `package.json` (e.g. `npm version 0.5.5 --no-git-tag-version`), and merge it. +2. Create a GitHub release whose tag is exactly that version, with no `v` prefix: `gh release create 0.5.5 --target master --generate-notes`. A prerelease version (`0.5.5-rc.0`) is staged under the `next` dist-tag instead of `latest`. +3. Once the workflow has passed, approve the staged version under Staged Packages on npmjs.com, or run `npm stage list prolly` and then `npm stage approve `. + ## License The MIT License (MIT) diff --git a/package.json b/package.json index 6f820d3..dd879f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prolly", - "version": "0.5.4", + "version": "0.5.5", "description": "A minimalist utility library for ES6 Native Promises", "main": "index.js", "scripts": {