From 183882037f1444c127118f5a8af2a34b849f3d5e Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Tue, 9 Jun 2026 17:36:28 +0100 Subject: [PATCH 1/4] Add OIDC staged-publishing release workflow Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 72 +++++++++++++++++++++++++++++++++++ .nvmrc | 1 + 2 files changed, 73 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..981e64e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,72 @@ +# CAVEAT: the publishable package (cordova-plugin-intercom) lives in the +# `intercom-plugin/` subdirectory, NOT the repo root. There is no root +# package.json. The verify job's version assertion and the stage-publish +# step below reference `./package.json`, so this workflow must run from the +# package directory. The two editable regions (install/build + dist-tag) +# have been adapted; the verbatim template lines assume a root package and +# will need the package directory wiring resolved before this is enabled. +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 JS install/build: cordova-plugin-intercom ships its source as-is, has no lockfile and no build/prepare script, so the publishable artifact already exists in the tree (matching the existing CircleCI publish job, which only ran `npm publish`). + - 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 6031a0f97fa9c44780432229bee045cf31622dbf Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Wed, 10 Jun 2026 16:27:05 +0100 Subject: [PATCH 2/4] 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 981e64e..dc05b0e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,12 +14,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 @@ -35,13 +41,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 58b1666283b1776df1633b160ea37340dabde715 Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Wed, 10 Jun 2026 17:40:27 +0100 Subject: [PATCH 3/4] 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 dc05b0e..da8bb15 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,8 +15,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: @@ -25,7 +25,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 @@ -47,7 +47,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 From ccf1bc42ae796bf9d8b204f8980370334728c334 Mon Sep 17 00:00:00 2001 From: Pat O'Callaghan Date: Wed, 17 Jun 2026 10:10:48 +0100 Subject: [PATCH 4/4] Address review: wire publish to intercom-plugin/, drop CircleCI publish, pin SHA between jobs - Run version assert, dist-tag resolve, and `npm stage publish` inside intercom-plugin/ (the publishable package lives there; no root package.json). - Remove the CircleCI token-based publish job and its workflow wiring so releases no longer double-publish / race on the dist-tag. - verify now outputs the ancestry-checked SHA; stage-publish checks out that exact SHA instead of re-resolving the mutable tag (closes the TOCTOU window). - Remove the resolved CAVEAT template header. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 18 ++++++++++-------- circle.yml | 22 ---------------------- 2 files changed, 10 insertions(+), 30 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index da8bb15..543ba00 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,10 +1,3 @@ -# CAVEAT: the publishable package (cordova-plugin-intercom) lives in the -# `intercom-plugin/` subdirectory, NOT the repo root. There is no root -# package.json. The verify job's version assertion and the stage-publish -# step below reference `./package.json`, so this workflow must run from the -# package directory. The two editable regions (install/build + dist-tag) -# have been adapted; the verbatim template lines assume a root package and -# will need the package directory wiring resolved before this is enabled. name: Publish (staged) on: @@ -21,6 +14,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: @@ -31,18 +26,21 @@ jobs: 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 + working-directory: ./intercom-plugin # publishable package lives here; there is no root package.json 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 + 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 +51,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-file: '.nvmrc' @@ -63,6 +63,7 @@ jobs: - 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 + working-directory: ./intercom-plugin env: PRERELEASE_TAG: next run: | @@ -73,6 +74,7 @@ jobs: esac echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Stage publish + working-directory: ./intercom-plugin # cordova-plugin-intercom lives here, not the repo root env: DIST_TAG: ${{ steps.disttag.outputs.tag }} run: npm stage publish --tag "$DIST_TAG" diff --git a/circle.yml b/circle.yml index 05139b3..46668be 100644 --- a/circle.yml +++ b/circle.yml @@ -57,19 +57,6 @@ jobs: name: Build command: cd Example && cordova build android --verbose -- --gradleArg=--stacktrace - publish: - executor: - name: android/android-machine - resource-class: large - tag: default - steps: - - checkout - - run: - name: Publish to npm - command: | - echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > intercom-plugin/.npmrc - cd intercom-plugin && npm publish - workflows: version: 2.1 ios_and_android: @@ -82,12 +69,3 @@ workflows: filters: tags: only: /[0-9]+(\.[0-9]+)+/ - - publish: - requires: - - ios - - android - filters: - tags: - only: /[0-9]+(\.[0-9]+)+/ - branches: - ignore: /.*/