From 39b37d31020399322411b110d47fafd2f7efb207 Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Fri, 12 Jun 2026 13:09:21 +0100 Subject: [PATCH 1/2] Add staged OIDC publish workflow Replace token-based npm publishing with OIDC trusted publishing + npm staged publishing: CI authenticates with a short-lived OIDC token (no stored npm token) and stages the release; a maintainer promotes it from the npm staging area with 2FA. The verify job asserts the Release tag matches package.json and refuses releases not reachable from the default branch. Listed in .fernignore so it is not overwritten by code generation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .fernignore | 3 +- .github/workflows/publish.yml | 84 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish.yml diff --git a/.fernignore b/.fernignore index 4c7324f1..7d2c0c45 100644 --- a/.fernignore +++ b/.fernignore @@ -5,4 +5,5 @@ LICENSE REPO_OWNER tests/integration -.github/workflows/ci.yml \ No newline at end of file +.github/workflows/ci.yml +.github/workflows/publish.yml \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..e6820269 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,84 @@ +# Release workflow for intercom-client. +# +# Publishing model: OIDC trusted publishing + npm staged publishing. CI authenticates to +# npm with a short-lived OIDC token (no stored npm token) and stages the release; a +# maintainer then promotes it from the npm staging area with 2FA. Replaces token-based publishing. +# +# Listed in .fernignore so it is not overwritten by code generation. +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 + +concurrency: + group: publish-${{ github.workflow }} # serialize publishes; no dist-tag races + cancel-in-progress: false # queue, don't kill an in-flight publish + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 # full history for the ancestry check below + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' # npm >= 11.15.0 needs Node >= 20; repo has no .nvmrc + 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 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 # cap a hung publish + 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: '22' + registry-url: 'https://registry.npmjs.org' + package-manager-cache: false + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm run build # mandatory: dist/ is gitignored, so the published artifact is built here + - 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: + ALPHA_TAG: alpha + BETA_TAG: beta + PRERELEASE_TAG: next + run: | + VERSION="$(node -p "require('./package.json').version")" + case "$VERSION" in + *-alpha.*) TAG="$ALPHA_TAG" ;; + *-beta.*) TAG="$BETA_TAG" ;; + *-*) 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 ea92a5db1b0dd17d394961578438861d6e0435eb Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Wed, 17 Jun 2026 11:19:36 +0100 Subject: [PATCH 2/2] Pin stage-publish to the ancestry-checked SHA (close TOCTOU window) verify now outputs the validated $GITHUB_SHA; stage-publish checks out that exact SHA instead of re-resolving the mutable release tag, so the commit that is published is provably the one the branch-ancestry guard approved. Mirrors the hardening already in intercom-react-native. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e6820269..cb46eb44 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,6 +21,8 @@ concurrency: jobs: verify: runs-on: ubuntu-latest + outputs: + sha: ${{ steps.resolve.outputs.sha }} # ancestry-checked commit, pinned for downstream steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -37,12 +39,14 @@ jobs: 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 + id: resolve env: RELEASE_TAG: ${{ github.event.release.tag_name }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH" \ || { echo "release $RELEASE_TAG not reachable from $DEFAULT_BRANCH — refusing"; exit 1; } + echo "sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" # downstream checks out this exact SHA, not the mutable tag stage-publish: needs: verify @@ -53,7 +57,9 @@ jobs: id-token: write # OIDC trusted publishing: only this job mints the token steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: { persist-credentials: false } + with: + persist-credentials: false + ref: ${{ needs.verify.outputs.sha }} # the ancestry-checked SHA, immune to tag re-pointing (TOCTOU) - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22'