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/__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/); }); 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