From 25af5a81af72c93d728e5f029deecb7a655c2f83 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 02:59:09 +0000 Subject: [PATCH 1/2] ci: publish only when the commit is already on master npm publish follows the tag, so an open release PR could ship before it was merged. Fail the workflow first unless HEAD is contained in master. Co-authored-by: Perry --- .github/workflows/publish.yml | 32 +++++++++-- __tests__/require-commit-on-master.test.js | 62 ++++++++++++++++++++++ scripts/require-commit-on-master.sh | 20 +++++++ 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 __tests__/require-commit-on-master.test.js create mode 100755 scripts/require-commit-on-master.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 53029ca..26ce2f0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,10 +11,13 @@ name: Publish to npm # B) Legacy token: add NPM_AUTH_TOKEN (npm granular publish token) to repo secrets # # Release: -# 1. Bump version in package.json on master (9.x) or 7.x -# 2. git tag vX.Y.Z && git push origin vX.Y.Z -# Tag must match package.json version (e.g. v9.4.1) -# Or: Actions → Publish to npm → Run workflow (manual dispatch) +# 1. Pull request: bump package.json and CHANGELOG. Merge it to master. +# 2. Tag that merge commit and push the tag: +# git tag vX.Y.Z && git push origin vX.Y.Z +# The tag commit must already be on master. A tag on an open pull +# request fails before npm publish. Tag must match package.json +# (e.g. v9.5.2). +# Or: Actions → Publish to npm → Run workflow from master. # # After a 9.x minor (vX.Y.0): # 3. Add .github/announcements/vX.Y.md (H1 title + ``) @@ -36,8 +39,27 @@ concurrency: cancel-in-progress: false jobs: + on-master: + name: Require commit on master + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - name: Fetch master + run: git fetch origin master + + - name: Refuse publish unless this commit is on master + run: bash scripts/require-commit-on-master.sh + zip-interop: name: Zip interop (Node unzipper + Java ZipInputStream) + needs: on-master runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -71,7 +93,7 @@ jobs: publish: name: Publish to npm - needs: zip-interop + needs: [on-master, zip-interop] runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/__tests__/require-commit-on-master.test.js b/__tests__/require-commit-on-master.test.js new file mode 100644 index 0000000..efb4c20 --- /dev/null +++ b/__tests__/require-commit-on-master.test.js @@ -0,0 +1,62 @@ +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const script = path.join(__dirname, '..', 'scripts', 'require-commit-on-master.sh'); + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); +} + +function initRepo() { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'require-on-master-')); + git(cwd, ['init', '-b', 'master']); + git(cwd, ['config', 'user.email', 'test@example.com']); + git(cwd, ['config', 'user.name', 'test']); + fs.writeFileSync(path.join(cwd, 'package.json'), '{"version":"1.0.0"}\n'); + git(cwd, ['add', 'package.json']); + git(cwd, ['commit', '-m', 'init']); + return cwd; +} + +function run(cwd) { + try { + return execFileSync('bash', [script], { + cwd, + encoding: 'utf8', + env: { ...process.env, MASTER_REF: 'master' }, + }); + } catch (error) { + const output = `${error.stdout || ''}\n${error.stderr || ''}`; + error.message = `${error.message}\n${output}`; + throw error; + } +} + +describe('require-commit-on-master', () => { + test('accepts a commit that is already on master', () => { + const cwd = initRepo(); + const output = run(cwd); + expect(output).toMatch(/is on master/); + }); + + test('rejects a commit that exists only on an open branch', () => { + const cwd = initRepo(); + git(cwd, ['checkout', '-b', 'release']); + fs.writeFileSync(path.join(cwd, 'package.json'), '{"version":"1.0.1"}\n'); + git(cwd, ['commit', '-am', 'bump']); + expect(() => run(cwd)).toThrow(/not on master/); + }); + + test('accepts the merge commit after the branch lands on master', () => { + const cwd = initRepo(); + git(cwd, ['checkout', '-b', 'release']); + fs.writeFileSync(path.join(cwd, 'package.json'), '{"version":"1.0.1"}\n'); + git(cwd, ['commit', '-am', 'bump']); + git(cwd, ['checkout', 'master']); + git(cwd, ['merge', '--ff-only', 'release']); + const output = run(cwd); + expect(output).toMatch(/is on master/); + }); +}); diff --git a/scripts/require-commit-on-master.sh b/scripts/require-commit-on-master.sh new file mode 100755 index 0000000..593cf24 --- /dev/null +++ b/scripts/require-commit-on-master.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Refuse to publish unless HEAD is already contained in master. +# Tag pushes check out the tag, so this must fetch origin/master first. +set -euo pipefail + +REF="${MASTER_REF:-origin/master}" + +if ! git rev-parse --verify --quiet "$REF" >/dev/null; then + echo "::error::Missing ref ${REF}. Fetch master before this check." + exit 1 +fi + +HEAD_SHA="$(git rev-parse HEAD)" +if git merge-base --is-ancestor HEAD "$REF"; then + echo "Commit ${HEAD_SHA} is on ${REF}." + exit 0 +fi + +echo "::error::Commit ${HEAD_SHA} is not on ${REF}. Merge the release pull request, then tag that merge commit. npm publish does not run from an open pull request." +exit 1 From b915038eb07a8fbd9335ae72ee7d58019efd6a2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 03:07:40 +0000 Subject: [PATCH 2/2] test: expect publish to need the master check and zip-interop The publish job now depends on both gates, so the old needs: zip-interop assertion no longer matches the workflow. Co-authored-by: Perry --- __tests__/zip-interop.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/__tests__/zip-interop.test.js b/__tests__/zip-interop.test.js index 035b648..9debfbd 100644 --- a/__tests__/zip-interop.test.js +++ b/__tests__/zip-interop.test.js @@ -56,7 +56,8 @@ describe('zip interop gate (RNZA-16)', () => { path.join(__dirname, '..', '.github', 'workflows', 'publish.yml'), 'utf8' ); - expect(yml).toMatch(/needs:\s*zip-interop/); + expect(yml).toMatch(/needs:\s*\[on-master,\s*zip-interop\]/); + expect(yml).toMatch(/needs:\s*on-master/); expect(yml).toMatch(/verify-zip-interop\.js --fixtures/); });