From a4e868f4827f600a9ab940b9fa6e9fc4fd6c5a48 Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Tue, 9 Jun 2026 17:36:07 +0100 Subject: [PATCH 1/3] Add OIDC staged-publishing release workflow --- .github/workflows/publish.yml | 65 +++++++++++++++++++++++++++++++++++ .nvmrc | 1 + 2 files changed, 66 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 .nvmrc diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d6ed811 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,65 @@ +name: Publish (staged) + +on: + release: + types: [published] # cutting a Release creates the tag AND fires this + +permissions: + contents: read # workflow default (least privilege); only stage-publish also needs id-token, granted on that job + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: { persist-credentials: false } + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' # pin >= 22.14.0 + package-manager-cache: false # release-triggered: disable auto-cache (zizmor cache-poisoning) + - name: Assert Release tag matches package.json version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + PKG="$(node -p "require('./package.json').version")" + [ "${RELEASE_TAG#v}" = "$PKG" ] || { echo "tag $RELEASE_TAG != package.json v$PKG"; exit 1; } + - name: Refuse releases not on the default branch + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + git fetch origin "$DEFAULT_BRANCH" --depth=1 + git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH" \ + || { echo "release $RELEASE_TAG not reachable from $DEFAULT_BRANCH — refusing"; exit 1; } + + stage-publish: + needs: verify + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # OIDC trusted publishing: only this job mints the token + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: { persist-credentials: false } + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + registry-url: 'https://registry.npmjs.org' + package-manager-cache: false + # no install/build: package ships committed lib/ sources, no build/prepare script and no lockfile + - run: npm install -g npm@11.15.0 # npm CLI: staged publishing needs npm >= 11.15.0 + - name: Resolve dist-tag (a prerelease must never go to `latest`) + id: disttag + env: + PRERELEASE_TAG: next + run: | + VERSION="$(node -p "require('./package.json').version")" + case "$VERSION" in + *-*) TAG="$PRERELEASE_TAG" ;; + *) TAG="latest" ;; + esac + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - name: Stage publish + env: + DIST_TAG: ${{ steps.disttag.outputs.tag }} + run: npm stage publish --tag "$DIST_TAG" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..7d41c73 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.14.0 From 6ac2c05c4280b4e6a15f1d164a4c34f6539f290f Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Wed, 10 Jun 2026 16:02:16 +0100 Subject: [PATCH 2/3] Harden publish workflow: fix ancestry guard, serialize releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "refuse releases not on the default branch" check used a depth-1 checkout plus a depth-1 `git fetch`, so the tag commit and the default branch tip shared no history locally — `git merge-base --is-ancestor` could only pass when the tag was exactly the branch tip, failing closed on any legitimate release once the branch advanced. Use `fetch-depth: 0` and drop the re-shallowing manual fetch; this also removes the public-repo-only dependency on an anonymous `git fetch` (works on private repos too, since checkout fetches before stripping credentials). Add a top-level `concurrency:` group so overlapping releases serialize instead of racing for a dist-tag in the staging queue; `cancel-in-progress: false` to queue rather than kill an in-flight `npm stage publish`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d6ed811..f750de3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,12 +7,18 @@ on: permissions: contents: read # workflow default (least privilege); only stage-publish also needs id-token, granted on that job +concurrency: + group: publish-${{ github.workflow }} # serialize all publish runs; never two staged releases racing for a dist-tag + cancel-in-progress: false # queue, don't cancel: killing a half-done `npm stage publish` is the torn state we're avoiding + jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: { persist-credentials: false } + with: + persist-credentials: false + fetch-depth: 0 # full history so origin/ ancestry is computable (shallow fetch can't see shared history) - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: '.nvmrc' # pin >= 22.14.0 @@ -28,7 +34,6 @@ jobs: RELEASE_TAG: ${{ github.event.release.tag_name }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - git fetch origin "$DEFAULT_BRANCH" --depth=1 git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH" \ || { echo "release $RELEASE_TAG not reachable from $DEFAULT_BRANCH — refusing"; exit 1; } From 3e763cb5f4b72718706dbff31709e62506a4c7ae Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Wed, 10 Jun 2026 16:18:56 +0100 Subject: [PATCH 3/3] Bound publish job with timeout-minutes: 15 Match the cli workflow: cap stage-publish so a hung publish fails fast instead of running to the 6h default. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f750de3..9528434 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,6 +40,7 @@ jobs: stage-publish: needs: verify runs-on: ubuntu-latest + timeout-minutes: 15 # bound a hung publish instead of running to the 6h default permissions: contents: read id-token: write # OIDC trusted publishing: only this job mints the token