From b3e9ad91410f0e72ec90b7a039174e95429c7888 Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Tue, 9 Jun 2026 17:36:05 +0100 Subject: [PATCH 1/3] Add OIDC staged-publishing release workflow Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..e6104f93 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,67 @@ +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 + - run: corepack enable + - run: yarn install --immutable + - run: yarn prepare + - 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: beta + 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" From bcc170798a4d0dbe80745d204d9c690f158d2c28 Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Wed, 10 Jun 2026 16:30:23 +0100 Subject: [PATCH 2/3] Harden publish workflow: fix ancestry guard, serialize releases, bound runtime Apply the fixes validated on the passport-intercom and cli publish workflows: - verify: use fetch-depth: 0 and drop the manual `git fetch --depth=1`, so the default-branch ancestry check has the history it needs (the double-shallow version could only pass when the tag was the branch tip) - add a top-level concurrency group so overlapping releases serialize instead of racing for a dist-tag - add timeout-minutes: 15 to stage-publish Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e6104f93..d5d3997c 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; checkout fetches authenticated before stripping creds - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: '.nvmrc' # pin >= 22.14.0 @@ -28,13 +34,13 @@ 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; } 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 From 8a88a21eab9092c92c694bfaa90c623dccd25be0 Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Wed, 10 Jun 2026 17:40:25 +0100 Subject: [PATCH 3/3] Trim verbose comments on the hardening changes Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d5d3997c..37b1de91 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,8 +8,8 @@ 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 + group: publish-${{ github.workflow }} # serialize publishes; no dist-tag races + cancel-in-progress: false # queue, don't kill an in-flight publish jobs: verify: @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - fetch-depth: 0 # full history so origin/ ancestry is computable; checkout fetches authenticated before stripping creds + fetch-depth: 0 # full history for the ancestry check below - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: '.nvmrc' # pin >= 22.14.0 @@ -40,7 +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 + timeout-minutes: 15 # cap a hung publish permissions: contents: read id-token: write # OIDC trusted publishing: only this job mints the token