From 7f1d49935c814284a44a9aa6b73aea14cc7fa329 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 12 Jun 2026 13:29:45 -0400 Subject: [PATCH] ci(#1104): keep next package.json in sync with the last published release (#1109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(#1104): sync next package.json version to the last published release next rested on a -dev stream per ADR-660 (1.3.1-dev.0) — a never-published placeholder that leaked to source/dev installs. Make every release type write its exact published version back to next: - finalize/hotfix (push main): auto-backmerge sets next's version to main's released version, folded into the existing back-merge PR (+ pinned setup-node). - rc (no main push): the rc job opens + admin-merges a sync PR after publish. Shared, fail-closed scripts/sync-next-version.cjs stamps package.json + the runtime manifests via the npm version hook and refuses any non-release version. Amends ADR-660 (supersedes the -dev stream decision). Co-Authored-By: Claude Opus 4.8 * ci(#1104): harden next-version sync against post-publish failure modes Review hardening (Codex + code-review gates) on the #1104 sync helper and its workflow callers: - release.yml rc Sync step: continue-on-error so a post-publish sync hiccup cannot fail an already-published release (npm immutability would block re-run). - auto-backmerge.yml inline sync: set -euo pipefail + validate VERSION before any shell use (closes a ${VERSION}-in-commit-message injection vector); git add -u instead of -A. - sync-next-version.cjs: reuse an existing open PR instead of failing gh pr create on rc re-runs; regex-parse the PR number and fail loud; discriminate the git diff --cached --quiet exit code (only status 1 == has-diff, else rethrow); git add -u to avoid sweeping runner artifacts into next; tolerate already-merged on admin merge. - tests: +2 (existing-PR reuse, non-diff rethrow); 14/14 pass. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- .github/workflows/auto-backmerge.yml | 21 +++ .github/workflows/release.yml | 8 + docs/adr/660-release-from-next-head.md | 23 +++ scripts/sync-next-version.cjs | 133 +++++++++++++ tests/sync-next-version.test.cjs | 248 +++++++++++++++++++++++++ 5 files changed, 433 insertions(+) create mode 100644 scripts/sync-next-version.cjs create mode 100644 tests/sync-next-version.test.cjs diff --git a/.github/workflows/auto-backmerge.yml b/.github/workflows/auto-backmerge.yml index 2c7bbe588..110629122 100644 --- a/.github/workflows/auto-backmerge.yml +++ b/.github/workflows/auto-backmerge.yml @@ -24,6 +24,9 @@ permissions: contents: write pull-requests: write +env: + NODE_VERSION: 24 + jobs: backmerge: # Phase-1 gate: leave false until `next` exists. Flip to `true` in Phase 2. @@ -36,6 +39,10 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Verify next branch exists id: check run: | @@ -99,6 +106,20 @@ jobs: echo "reused=false" >> "$GITHUB_OUTPUT" fi + - name: Sync next's version to main's released version + if: steps.check.outputs.next_exists == 'true' + run: | + set -euo pipefail + VERSION=$(git show origin/main:package.json | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).version") + case "$VERSION" in + [0-9]*.[0-9]*.[0-9]*) : ;; + *) echo "refusing to sync next: unexpected version '$VERSION' from origin/main" >&2; exit 1 ;; + esac + node scripts/sync-next-version.cjs "$VERSION" --in-place + git add -u + git diff --cached --quiet || git commit -m "chore: sync next package version to ${VERSION}" + git push origin HEAD + - name: Open or update PR if: steps.check.outputs.next_exists == 'true' id: openpr diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a0c6de93..841fde9e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -463,6 +463,14 @@ jobs: PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} run: node scripts/verify-npm-publish.cjs --package @opengsd/gsd-core --version "$PRE_VERSION" --dist-tag next + - name: Sync next branch to the published pre-release + if: ${{ !inputs.dry_run }} + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} + PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} + run: node scripts/sync-next-version.cjs "$PRE_VERSION" + - name: Summary env: PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} diff --git a/docs/adr/660-release-from-next-head.md b/docs/adr/660-release-from-next-head.md index ea531257c..94e49ce8b 100644 --- a/docs/adr/660-release-from-next-head.md +++ b/docs/adr/660-release-from-next-head.md @@ -151,3 +151,26 @@ right; only the *movable placeholder* mechanic was wrong. *(Recommend: keep the rc tags — harmless, immutable, and they anchor the GitHub prerelease.)* 3. `-dev` floor increment: next-patch (`A.B.(C+1)-dev.0`, the precedence-safe default above) or next-minor (`A.(B+1).0-dev.0`)? *(Recommend: next-patch floor.)* + +## Amendment (2026-06-12, #1104): `next` tracks the last published release + +**Supersedes** the §2 / "Resolved by maintainer" choice to rest `next` on a `-dev` stream. + +The `-dev` floor (e.g. `1.3.1-dev.0`) was never published to npm, yet it became the +source-of-truth version on the default branch and leaked to the real world via source/dev +installs that report `package.json`'s version — a version no release ever bore. To eliminate +phantom versions, `next` now **rests at the last published release** and is synced +automatically by the release pipeline for **every** release type: + +- **finalize / hotfix** (these push `main`): the existing `main → next` back-merge + (`.github/workflows/auto-backmerge.yml`) sets `next`'s version to `main`'s released version, + folded into the same back-merge PR. +- **rc** (publishes a pre-release to the `release/` branch + `@next`; does **not** push + `main`): the `rc` job in `.github/workflows/release.yml` opens and admin-merges a + `chore: sync next package version` PR after a confirmed publish. + +Both paths share `scripts/sync-next-version.cjs`, which sets `package.json` and stamps the +runtime manifests (`plugin.json`, `gemini-extension.json`) via the `version` lifecycle hook, +and **refuses any non-release version string** (fail-closed — a `-dev`/placeholder can never be +written to `next` again). Open question 3 (the `-dev` floor increment) is therefore moot: there +is no `-dev` floor. diff --git a/scripts/sync-next-version.cjs b/scripts/sync-next-version.cjs new file mode 100644 index 000000000..68d4d7da3 --- /dev/null +++ b/scripts/sync-next-version.cjs @@ -0,0 +1,133 @@ +'use strict'; +/** + * Sync the `next` branch's package.json version to a published release version. + * + * The release pipeline bumps the version only on the release/X.Y.Z branch at + * publish time, so `next` drifts and can carry a never-published placeholder + * (e.g. 1.3.1-dev.0) that leaks to source/dev installs reporting package.json + * version. This keeps `next` equal to the last published release for every + * release type (rc / hotfix / final). See issue #1104. + * + * Modes: + * default — open + admin-merge a `chore: sync next version` PR (used by + * release.yml's rc job, which has no next-targeting PR of its + * own). Idempotent: a no-op when `next` is already at the target. + * --in-place — set the version (+ manifests, via the npm `version` hook) in + * the current working tree only; the caller commits/pushes (used + * by auto-backmerge.yml, which folds it into its existing + * main->next PR). + * + * Subprocesses are bounded (repo convention) and the exec seam is injectable so + * the orchestration is unit-testable without git/gh/npm. + */ +const { execFileSync } = require('child_process'); + +const RELEASE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta)\.[0-9]+)?$/; + +/** True iff `v` is a publishable release version: X.Y.Z optionally -rc.N / -beta.N. Rejects -dev and anything else. */ +function isReleaseVersion(v) { + return typeof v === 'string' && RELEASE_VERSION.test(v); +} + +/** Read the `version` field out of a package.json text blob. Throws on a missing/non-string version. */ +function versionFromPackageJson(text) { + const parsed = JSON.parse(text); + if (typeof parsed.version !== 'string') throw new Error('package.json has no string version'); + return parsed.version; +} + +function defaultRun(cmd, args, opts = {}) { + return execFileSync(cmd, args, { + encoding: 'utf8', + timeout: opts.timeout ?? 30000, + stdio: opts.stdio ?? ['ignore', 'pipe', 'inherit'], + }); +} + +/** Set package.json version (and manifests, via the npm `version` lifecycle hook) in the current tree. */ +function applyVersion(version, { run = defaultRun } = {}) { + if (!isReleaseVersion(version)) { + throw new Error(`refusing to sync next to invalid/non-release version '${version}'`); + } + run('npm', ['version', version, '--no-git-tag-version', '--allow-same-version'], { timeout: 60000 }); +} + +/** Full PR-based sync to the `next` branch. Returns 'noop' | 'synced'. */ +function syncViaPr(version, { run = defaultRun } = {}) { + if (!isReleaseVersion(version)) { + throw new Error(`refusing to sync next to invalid/non-release version '${version}'`); + } + run('git', ['fetch', 'origin', 'next'], { timeout: 30000 }); + const current = versionFromPackageJson(run('git', ['show', 'origin/next:package.json'], { timeout: 15000 })); + if (current === version) { + process.stdout.write(`next already at ${version} — nothing to sync\n`); + return 'noop'; + } + const branch = `chore/sync-next-version-${version}`; + run('git', ['checkout', '-B', branch, 'origin/next'], { timeout: 15000 }); + applyVersion(version, { run }); + run('git', ['add', '-u'], { timeout: 15000 }); + // `git diff --cached --quiet` exits 0 when there is NO staged diff (execFileSync + // returns), exits 1 when there IS one (execFileSync throws). Proceed only on a diff. + let hasDiff = false; + try { + run('git', ['diff', '--cached', '--quiet'], { timeout: 15000 }); + } catch (err) { + // exit 1 == there is a staged diff (expected); anything else is a real failure. + if (err && err.status === 1) hasDiff = true; + else throw err; + } + if (!hasDiff) { + process.stdout.write('no changes to commit — nothing to sync\n'); + return 'noop'; + } + run('git', ['commit', '-m', `chore: sync next package version to ${version}`], { timeout: 15000 }); + run('git', ['push', '--force-with-lease', 'origin', branch], { timeout: 60000 }); + let prUrl = run('gh', ['pr', 'list', '--head', branch, '--base', 'next', '--state', 'open', + '--json', 'url', '--jq', '.[0].url // ""'], { timeout: 30000 }).trim(); + if (!prUrl) { + prUrl = run('gh', ['pr', 'create', '--base', 'next', '--head', branch, + '--title', `chore: sync next package version to ${version}`, + '--body', `Automated: keep \`next\`'s package.json at the last published release (\`${version}\`) so the default branch never carries a never-published version. Generated by the release pipeline (#1104).`, + ], { timeout: 60000 }).trim(); + } + const m = prUrl.match(/\/pull\/(\d+)\b/); + if (!m) throw new Error(`could not parse PR number from gh output: ${prUrl}`); + const prNum = m[1]; + try { + run('gh', ['pr', 'edit', prNum, '--add-label', 'automation', '--add-label', 'no-changelog'], { timeout: 30000 }); + } catch { + /* labels are best-effort; admin-merge below still lands the PR */ + } + try { + run('gh', ['pr', 'merge', '--admin', '--merge', prNum], { timeout: 60000 }); + } catch (err) { + // tolerate "already merged"; rethrow anything else + const msg = String((err && (err.stderr || err.message)) || ''); + if (!/already merged|not mergeable|Merged/i.test(msg)) throw err; + } + process.stdout.write(`synced next -> ${version} via PR #${prNum}\n`); + return 'synced'; +} + +function main(argv = process.argv.slice(2), deps = {}) { + const inPlace = argv.includes('--in-place'); + const version = argv.find((a) => !a.startsWith('--')); + if (!version) throw new Error('usage: sync-next-version.cjs [--in-place]'); + if (inPlace) { + applyVersion(version, deps); + return; + } + syncViaPr(version, deps); +} + +if (require.main === module) { + try { + main(); + } catch (e) { + process.stderr.write(`error: ${e.message}\n`); + process.exitCode = 1; + } +} + +module.exports = { isReleaseVersion, versionFromPackageJson, applyVersion, syncViaPr, main }; diff --git a/tests/sync-next-version.test.cjs b/tests/sync-next-version.test.cjs new file mode 100644 index 000000000..5c0adb526 --- /dev/null +++ b/tests/sync-next-version.test.cjs @@ -0,0 +1,248 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { isReleaseVersion, versionFromPackageJson, applyVersion, syncViaPr, main } = require('../scripts/sync-next-version.cjs'); + +// --------------------------------------------------------------------------- +// Run-stub factory +// --------------------------------------------------------------------------- +/** + * Creates a run stub for injection into applyVersion/syncViaPr/main. + * + * `responses` is an array of matchers: + * { cmd, args0, returns } — if cmd matches and (args0 is set) args[0] matches → return `returns` + * { cmd, args0, throws } — same match but throws the value + * + * Unmatched calls return '' by default. + */ +function makeRun(responses = []) { + const calls = []; + function run(cmd, args) { + calls.push({ cmd, args: [...args] }); + for (const r of responses) { + const cmdMatch = !r.cmd || r.cmd === cmd; + const args0Match = r.args0 === undefined || r.args0 === args[0]; + const args1Match = r.args1 === undefined || r.args1 === args[1]; + if (cmdMatch && args0Match && args1Match) { + if ('throws' in r) throw r.throws; + return r.returns ?? ''; + } + } + return ''; + } + run.calls = calls; + return run; +} + +// --------------------------------------------------------------------------- +// A. isReleaseVersion +// --------------------------------------------------------------------------- +test('isReleaseVersion — true for valid release versions', () => { + for (const v of ['1.5.0', '1.5.0-rc.2', '1.5.0-beta.1', '10.20.30', '0.0.1', '1.5.0-rc.10']) { + assert.equal(isReleaseVersion(v), true, `expected true for '${v}'`); + } +}); + +test('isReleaseVersion — false for invalid/non-release versions', () => { + for (const v of ['1.3.1-dev.0', '1.5.0-dev.0', '1.5', 'v1.5.0', '1.5.0-rc', '1.5.0-rc.x', '1.5.0-alpha.1', '', ' 1.5.0', null, undefined, 123]) { + assert.equal(isReleaseVersion(v), false, `expected false for ${JSON.stringify(v)}`); + } +}); + +// --------------------------------------------------------------------------- +// B. versionFromPackageJson +// --------------------------------------------------------------------------- +test('versionFromPackageJson — returns version for valid JSON', () => { + assert.equal(versionFromPackageJson('{"version":"1.5.0-rc.2"}'), '1.5.0-rc.2'); +}); + +test('versionFromPackageJson — throws for missing version field', () => { + assert.throws(() => versionFromPackageJson('{}'), /no string version/); +}); + +test('versionFromPackageJson — throws for invalid JSON', () => { + assert.throws(() => versionFromPackageJson('not json')); +}); + +// --------------------------------------------------------------------------- +// C. applyVersion +// --------------------------------------------------------------------------- +test('applyVersion — throws for invalid version without calling run', () => { + const run = makeRun(); + assert.throws( + () => applyVersion('1.3.1-dev.0', { run }), + /refusing to sync next to invalid\/non-release version/, + ); + assert.equal(run.calls.length, 0, 'run should not have been called'); +}); + +test('applyVersion — calls npm version once with correct args for valid version', () => { + const run = makeRun([{ cmd: 'npm', returns: '' }]); + applyVersion('1.5.0', { run }); + assert.equal(run.calls.length, 1); + const [call] = run.calls; + assert.equal(call.cmd, 'npm'); + assert.deepEqual(call.args, ['version', '1.5.0', '--no-git-tag-version', '--allow-same-version']); +}); + +// --------------------------------------------------------------------------- +// D. syncViaPr — idempotent (version already matches) +// --------------------------------------------------------------------------- +test('syncViaPr — noop when next is already at the target version', () => { + const run = makeRun([ + // git fetch — just returns + { cmd: 'git', args0: 'fetch', returns: '' }, + // git show — returns the SAME version as target + { cmd: 'git', args0: 'show', returns: '{"version":"1.5.0-rc.2"}' }, + ]); + const result = syncViaPr('1.5.0-rc.2', { run }); + assert.equal(result, 'noop'); + // Must NOT have called checkout, commit, or gh + const cmdArgs = run.calls.map((c) => `${c.cmd} ${c.args[0]}`); + assert.ok(!cmdArgs.some((s) => s.includes('checkout')), 'should not checkout'); + assert.ok(!cmdArgs.some((s) => s.includes('commit')), 'should not commit'); + assert.ok(!cmdArgs.some((s) => s === 'gh pr'), 'should not call gh'); + assert.ok(!run.calls.some((c) => c.cmd === 'gh'), 'should not call gh at all'); +}); + +// --------------------------------------------------------------------------- +// E. syncViaPr — happy path (version differs, diff present) +// --------------------------------------------------------------------------- +test('syncViaPr — happy path: syncs and returns "synced"', () => { + const run = makeRun([ + { cmd: 'git', args0: 'fetch', returns: '' }, + { cmd: 'git', args0: 'show', returns: '{"version":"1.4.0"}' }, + { cmd: 'git', args0: 'checkout', returns: '' }, + { cmd: 'npm', returns: '' }, + { cmd: 'git', args0: 'add', returns: '' }, + // diff --cached --quiet throws with status 1 → there IS a staged diff + { cmd: 'git', args0: 'diff', throws: Object.assign(new Error('diff'), { status: 1 }) }, + { cmd: 'git', args0: 'commit', returns: '' }, + { cmd: 'git', args0: 'push', returns: '' }, + // gh pr list returns '' → no existing PR, so create path runs + { cmd: 'gh', args0: 'pr', args1: 'list', returns: '' }, + { cmd: 'gh', args0: 'pr', args1: 'create', returns: 'https://github.com/o/r/pull/777\n' }, + { cmd: 'gh', args0: 'pr', args1: 'edit', returns: '' }, + { cmd: 'gh', args0: 'pr', args1: 'merge', returns: '' }, + ]); + + const result = syncViaPr('1.5.0-rc.2', { run }); + assert.equal(result, 'synced'); + + // Verify ordered calls: checkout -B, npm version, git commit, git push, gh pr create, gh pr merge --admin + const calls = run.calls; + + const checkoutIdx = calls.findIndex((c) => c.cmd === 'git' && c.args[0] === 'checkout'); + assert.ok(checkoutIdx >= 0, 'should have checkout'); + assert.equal(calls[checkoutIdx].args[1], '-B'); + + const npmIdx = calls.findIndex((c) => c.cmd === 'npm'); + assert.ok(npmIdx > checkoutIdx, 'npm version after checkout'); + + const commitIdx = calls.findIndex((c) => c.cmd === 'git' && c.args[0] === 'commit'); + assert.ok(commitIdx > npmIdx, 'commit after npm version'); + + const pushIdx = calls.findIndex((c) => c.cmd === 'git' && c.args[0] === 'push'); + assert.ok(pushIdx > commitIdx, 'push after commit'); + + const prCreateIdx = calls.findIndex((c) => c.cmd === 'gh' && c.args[0] === 'pr' && c.args[1] === 'create'); + assert.ok(prCreateIdx > pushIdx, 'gh pr create after push'); + + const prMergeIdx = calls.findIndex((c) => c.cmd === 'gh' && c.args[0] === 'pr' && c.args[1] === 'merge'); + assert.ok(prMergeIdx > prCreateIdx, 'gh pr merge after gh pr create'); + assert.ok(calls[prMergeIdx].args.includes('--admin'), 'merge uses --admin'); + + // Assert branch name + const checkoutCall = calls[checkoutIdx]; + const branchArg = checkoutCall.args[2]; // git checkout -B origin/next + assert.ok(branchArg.includes('chore/sync-next-version-1.5.0-rc.2'), `branch should contain 'chore/sync-next-version-1.5.0-rc.2', got '${branchArg}'`); +}); + +// --------------------------------------------------------------------------- +// F. syncViaPr — rejects invalid version before any run call +// --------------------------------------------------------------------------- +test('syncViaPr — throws for invalid version before calling run', () => { + const run = makeRun(); + assert.throws( + () => syncViaPr('1.3.1-dev.0', { run }), + /refusing to sync next to invalid\/non-release version/, + ); + assert.equal(run.calls.length, 0, 'run must not be called'); +}); + +// --------------------------------------------------------------------------- +// G. main — --in-place routes to applyVersion; no PR/fetch/checkout +// --------------------------------------------------------------------------- +test('main --in-place calls npm version and does not fetch/checkout/gh', () => { + const run = makeRun([{ cmd: 'npm', returns: '' }]); + main(['1.5.0', '--in-place'], { run }); + assert.ok(run.calls.some((c) => c.cmd === 'npm'), 'should call npm'); + assert.ok(!run.calls.some((c) => c.cmd === 'git' && c.args[0] === 'fetch'), 'should not fetch'); + assert.ok(!run.calls.some((c) => c.cmd === 'git' && c.args[0] === 'checkout'), 'should not checkout'); + assert.ok(!run.calls.some((c) => c.cmd === 'gh'), 'should not call gh'); +}); + +test('main (no --in-place) with matching version → noop via syncViaPr', () => { + const run = makeRun([ + { cmd: 'git', args0: 'fetch', returns: '' }, + { cmd: 'git', args0: 'show', returns: '{"version":"1.5.0"}' }, + ]); + // Should not throw; syncViaPr returns 'noop' + main(['1.5.0'], { run }); + assert.ok(!run.calls.some((c) => c.cmd === 'gh'), 'should not call gh on noop'); +}); + +// --------------------------------------------------------------------------- +// H. syncViaPr — reuses existing open PR +// --------------------------------------------------------------------------- +test('syncViaPr — reuses an existing open PR instead of creating', () => { + const run = makeRun([ + { cmd: 'git', args0: 'fetch', returns: '' }, + { cmd: 'git', args0: 'show', returns: '{"version":"1.4.0"}' }, + { cmd: 'git', args0: 'checkout', returns: '' }, + { cmd: 'npm', returns: '' }, + { cmd: 'git', args0: 'add', returns: '' }, + { cmd: 'git', args0: 'diff', throws: Object.assign(new Error('diff'), { status: 1 }) }, + { cmd: 'git', args0: 'commit', returns: '' }, + { cmd: 'git', args0: 'push', returns: '' }, + // gh pr list returns an existing PR URL + { cmd: 'gh', args0: 'pr', args1: 'list', returns: 'https://github.com/o/r/pull/555\n' }, + { cmd: 'gh', args0: 'pr', args1: 'edit', returns: '' }, + { cmd: 'gh', args0: 'pr', args1: 'merge', returns: '' }, + ]); + + const result = syncViaPr('1.5.0-rc.2', { run }); + assert.equal(result, 'synced'); + + // Must NOT have called gh pr create + assert.ok( + !run.calls.some((c) => c.cmd === 'gh' && c.args[0] === 'pr' && c.args[1] === 'create'), + 'should not call gh pr create when existing PR found', + ); + + // Admin merge must target PR 555 + const mergeCall = run.calls.find((c) => c.cmd === 'gh' && c.args[0] === 'pr' && c.args[1] === 'merge'); + assert.ok(mergeCall, 'should call gh pr merge'); + assert.ok(mergeCall.args.includes('555'), 'merge should target PR 555'); +}); + +// --------------------------------------------------------------------------- +// I. syncViaPr — rethrows when git diff fails for a non-diff reason +// --------------------------------------------------------------------------- +test('syncViaPr — rethrows when git diff fails for a non-diff reason', () => { + const run = makeRun([ + { cmd: 'git', args0: 'fetch', returns: '' }, + { cmd: 'git', args0: 'show', returns: '{"version":"1.4.0"}' }, + { cmd: 'git', args0: 'checkout', returns: '' }, + { cmd: 'npm', returns: '' }, + { cmd: 'git', args0: 'add', returns: '' }, + // git diff throws with status 128 → real error, not a diff signal + { cmd: 'git', args0: 'diff', throws: Object.assign(new Error('fatal: not a git repo'), { status: 128 }) }, + ]); + + assert.throws( + () => syncViaPr('1.5.0-rc.2', { run }), + /fatal: not a git repo/, + 'should rethrow the real error', + ); +});