From 6c8222fa2676668388079dddf295668756b85c8e Mon Sep 17 00:00:00 2001 From: Yihan Zhu <48186361+yihanzhu@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:05:15 -0400 Subject: [PATCH 1/2] ci: guard the compatibility epoch against silent same-number merges Two branches that bump RUNTIME_HOST_COMPATIBILITY_EPOCH write the same text to the same line, so git merges them without a conflict and two incompatible protocols advertise one epoch. Add a merge-base guard that runs on the PR merge result and fails when protocol files changed while the epoch still equals the merge base's, or when the epoch moves backward. This is the interim check from #3313; the derive-the-epoch question stays open for the dev list. Refs #3313 Generated-by: Claude Code (Fable 5) Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 13 ++++ scripts/protocol-epoch-check.mjs | 107 ++++++++++++++++++++++++++ scripts/protocol-epoch-check.test.mjs | 70 +++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 scripts/protocol-epoch-check.mjs create mode 100644 scripts/protocol-epoch-check.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a3fdcdb29..845c5daed4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,19 @@ jobs: - name: Check Windows test inventory run: npm run windows:inventory + # Runs on the PR merge result: after a sibling protocol change lands on + # main with the same epoch text, the silently merged tree still carries + # the base's epoch and this fails instead of shipping two incompatible + # protocols under one number (#3313). + - name: Guard the protocol compatibility epoch + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: node scripts/protocol-epoch-check.mjs --base "$BASE_SHA" + + - name: Test the epoch guard + run: node --test --test-concurrency=1 scripts/protocol-epoch-check.test.mjs + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' with: diff --git a/scripts/protocol-epoch-check.mjs b/scripts/protocol-epoch-check.mjs new file mode 100644 index 0000000000..7f3dc2cd9b --- /dev/null +++ b/scripts/protocol-epoch-check.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +// Merge-base guard for the Runtime Host compatibility epoch (#3313). +// +// Two branches that each bump the epoch write the same text to the same line, +// so git's three-way merge resolves them without a conflict and two +// incompatible protocols end up advertising one epoch. This check runs on the +// PR merge result and fails when anything under the protocol directory changed +// while the epoch still equals the merge base's — which is exactly the state a +// silent same-number merge produces. + +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const defaultRepoRoot = dirname(dirname(scriptPath)); + +export const EPOCH_FILE = 'packages/runtime-host/src/protocol/index.ts'; +export const PROTOCOL_DIR = 'packages/runtime-host/src/protocol/'; + +const EPOCH_PATTERN = /^export const RUNTIME_HOST_COMPATIBILITY_EPOCH = (\d+) as const;$/gm; + +export function extractCompatibilityEpoch(source) { + const matches = [...source.matchAll(EPOCH_PATTERN)]; + if (matches.length !== 1) { + throw new Error( + `Expected exactly one RUNTIME_HOST_COMPATIBILITY_EPOCH declaration in ${EPOCH_FILE}, found ${matches.length}`, + ); + } + return Number(matches[0][1]); +} + +export function evaluateEpochCheck({ baseEpoch, headEpoch, changedProtocolFiles }) { + if (headEpoch < baseEpoch) { + return { + ok: false, + reason: + `RUNTIME_HOST_COMPATIBILITY_EPOCH went backward: ${baseEpoch} -> ${headEpoch}. ` + + `The epoch never decreases — a peer that saw ${baseEpoch} would admit an ` + + `incompatible protocol. Bump it forward instead, even for a revert.`, + }; + } + if (changedProtocolFiles.length > 0 && headEpoch === baseEpoch) { + return { + ok: false, + reason: + `Protocol files changed but RUNTIME_HOST_COMPATIBILITY_EPOCH is still ${baseEpoch}, ` + + `the merge base's value. Same-number bumps on sibling branches merge without a git ` + + `conflict (#3313), so every protocol change must land with an epoch the merge base ` + + `has not seen: rebase onto current main and set the epoch past ${baseEpoch}. ` + + `Changed files:\n${changedProtocolFiles.map((file) => ` ${file}`).join('\n')}`, + }; + } + return { + ok: true, + reason: + changedProtocolFiles.length > 0 + ? `Protocol changed and the epoch moved: ${baseEpoch} -> ${headEpoch}.` + : `No protocol changes against the merge base (epoch ${headEpoch}).`, + }; +} + +function git(args, exec = execFileSync) { + return exec('git', args, { cwd: defaultRepoRoot, encoding: 'utf8' }); +} + +export function changedProtocolFilesBetween(base, head, exec = execFileSync) { + return git(['diff', '--no-renames', '--name-only', base, head, '--', PROTOCOL_DIR], exec) + .split('\n') + .filter(Boolean); +} + +export function epochAtRevision(revision, exec = execFileSync) { + return extractCompatibilityEpoch(git(['show', `${revision}:${EPOCH_FILE}`], exec)); +} + +function parseArgs(args) { + const parsed = { base: undefined, head: 'HEAD' }; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === '--base') parsed.base = args[++index]; + else if (args[index] === '--head') parsed.head = args[++index]; + else throw new Error(`Unknown argument: ${args[index]}`); + } + if (!parsed.base) throw new Error('Expected --base (and optionally --head )'); + return parsed; +} + +function main(args) { + const { base, head } = parseArgs(args); + const verdict = evaluateEpochCheck({ + baseEpoch: epochAtRevision(base), + headEpoch: epochAtRevision(head), + changedProtocolFiles: changedProtocolFilesBetween(base, head), + }); + process.stderr.write(`Protocol epoch guard: ${verdict.reason}\n`); + if (!verdict.ok) process.exitCode = 1; +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + try { + main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 2; + } +} diff --git a/scripts/protocol-epoch-check.test.mjs b/scripts/protocol-epoch-check.test.mjs new file mode 100644 index 0000000000..54572c2fca --- /dev/null +++ b/scripts/protocol-epoch-check.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + EPOCH_FILE, + evaluateEpochCheck, + extractCompatibilityEpoch, +} from './protocol-epoch-check.mjs'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +test('extracts the epoch from the declaration line', () => { + assert.equal( + extractCompatibilityEpoch('export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 27 as const;\n'), + 27, + ); +}); + +test('refuses a source with no epoch declaration or more than one', () => { + assert.throws(() => extractCompatibilityEpoch('export const OTHER = 1 as const;\n'), /found 0/); + assert.throws( + () => + extractCompatibilityEpoch( + 'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 27 as const;\n' + + 'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const;\n', + ), + /found 2/, + ); +}); + +test('parses the real protocol index, so the pattern cannot silently rot', () => { + const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + const source = readFileSync(join(repoRoot, EPOCH_FILE), 'utf8'); + assert.equal(Number.isInteger(extractCompatibilityEpoch(source)), true); +}); + +test('fails a protocol change whose epoch equals the merge base', () => { + const verdict = evaluateEpochCheck({ + baseEpoch: 27, + headEpoch: 27, + changedProtocolFiles: ['packages/runtime-host/src/protocol/operations.ts'], + }); + assert.equal(verdict.ok, false); + assert.match(verdict.reason, /still 27/); + assert.match(verdict.reason, /operations\.ts/); +}); + +test('fails any epoch decrease, protocol change or not', () => { + for (const changedProtocolFiles of [[], ['packages/runtime-host/src/protocol/index.ts']]) { + const verdict = evaluateEpochCheck({ baseEpoch: 28, headEpoch: 27, changedProtocolFiles }); + assert.equal(verdict.ok, false); + assert.match(verdict.reason, /went backward/); + } +}); + +test('passes a protocol change that moves the epoch forward', () => { + const verdict = evaluateEpochCheck({ + baseEpoch: 27, + headEpoch: 28, + changedProtocolFiles: ['packages/runtime-host/src/protocol/index.ts'], + }); + assert.equal(verdict.ok, true); +}); + +test('passes when nothing under the protocol directory changed', () => { + for (const headEpoch of [27, 28]) { + const verdict = evaluateEpochCheck({ baseEpoch: 27, headEpoch, changedProtocolFiles: [] }); + assert.equal(verdict.ok, true); + } +}); From d4d02321e7efc36e6b89864fe033637cc0631c93 Mon Sep 17 00:00:00 2001 From: Yihan Zhu <48186361+yihanzhu@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:31:03 -0400 Subject: [PATCH 2/2] ci: compare the protocol epoch with the merge first parent Use the synthetic merge's current-base parent instead of the PR's stale base snapshot, and cover two sibling same-number bumps with a real Git graph. Generated-by: Codex --- .github/workflows/ci.yml | 8 ++-- scripts/protocol-epoch-check.mjs | 17 ++++---- scripts/protocol-epoch-check.test.mjs | 61 ++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 845c5daed4..ba5a4d79d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,13 +52,11 @@ jobs: # Runs on the PR merge result: after a sibling protocol change lands on # main with the same epoch text, the silently merged tree still carries - # the base's epoch and this fails instead of shipping two incompatible - # protocols under one number (#3313). + # the current base parent's epoch and this fails instead of shipping two + # incompatible protocols under one number (#3313). - name: Guard the protocol compatibility epoch if: github.event_name == 'pull_request' - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: node scripts/protocol-epoch-check.mjs --base "$BASE_SHA" + run: node scripts/protocol-epoch-check.mjs --base 'HEAD^1' - name: Test the epoch guard run: node --test --test-concurrency=1 scripts/protocol-epoch-check.test.mjs diff --git a/scripts/protocol-epoch-check.mjs b/scripts/protocol-epoch-check.mjs index 7f3dc2cd9b..f27c49a6ba 100644 --- a/scripts/protocol-epoch-check.mjs +++ b/scripts/protocol-epoch-check.mjs @@ -1,13 +1,14 @@ #!/usr/bin/env node -// Merge-base guard for the Runtime Host compatibility epoch (#3313). +// Merge-result guard for the Runtime Host compatibility epoch (#3313). // // Two branches that each bump the epoch write the same text to the same line, // so git's three-way merge resolves them without a conflict and two // incompatible protocols end up advertising one epoch. This check runs on the -// PR merge result and fails when anything under the protocol directory changed -// while the epoch still equals the merge base's — which is exactly the state a -// silent same-number merge produces. +// PR merge result and compares it with the synthetic merge's first parent: the +// current base branch. It fails when anything under the protocol directory +// changed while the epoch still equals that parent — exactly the state a silent +// same-number merge produces. import { execFileSync } from 'node:child_process'; import { dirname, resolve } from 'node:path'; @@ -46,9 +47,9 @@ export function evaluateEpochCheck({ baseEpoch, headEpoch, changedProtocolFiles ok: false, reason: `Protocol files changed but RUNTIME_HOST_COMPATIBILITY_EPOCH is still ${baseEpoch}, ` + - `the merge base's value. Same-number bumps on sibling branches merge without a git ` + - `conflict (#3313), so every protocol change must land with an epoch the merge base ` + - `has not seen: rebase onto current main and set the epoch past ${baseEpoch}. ` + + `the current base parent's value. Same-number bumps on sibling branches merge without ` + + `a git conflict (#3313), so every protocol change must land with an epoch the current ` + + `base has not seen: rebase onto current main and set the epoch past ${baseEpoch}. ` + `Changed files:\n${changedProtocolFiles.map((file) => ` ${file}`).join('\n')}`, }; } @@ -57,7 +58,7 @@ export function evaluateEpochCheck({ baseEpoch, headEpoch, changedProtocolFiles reason: changedProtocolFiles.length > 0 ? `Protocol changed and the epoch moved: ${baseEpoch} -> ${headEpoch}.` - : `No protocol changes against the merge base (epoch ${headEpoch}).`, + : `No protocol changes against the current base parent (epoch ${headEpoch}).`, }; } diff --git a/scripts/protocol-epoch-check.test.mjs b/scripts/protocol-epoch-check.test.mjs index 54572c2fca..2a0a666a39 100644 --- a/scripts/protocol-epoch-check.test.mjs +++ b/scripts/protocol-epoch-check.test.mjs @@ -1,11 +1,15 @@ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import test from 'node:test'; import { + changedProtocolFilesBetween, EPOCH_FILE, + epochAtRevision, evaluateEpochCheck, extractCompatibilityEpoch, } from './protocol-epoch-check.mjs'; -import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -34,7 +38,7 @@ test('parses the real protocol index, so the pattern cannot silently rot', () => assert.equal(Number.isInteger(extractCompatibilityEpoch(source)), true); }); -test('fails a protocol change whose epoch equals the merge base', () => { +test('fails a protocol change whose epoch equals the current base parent', () => { const verdict = evaluateEpochCheck({ baseEpoch: 27, headEpoch: 27, @@ -45,6 +49,59 @@ test('fails a protocol change whose epoch equals the merge base', () => { assert.match(verdict.reason, /operations\.ts/); }); +test('catches sibling same-number bumps against the synthetic merge first parent', () => { + const repo = mkdtempSync(join(tmpdir(), 'maka-protocol-epoch-graph-')); + const epochPath = join(repo, EPOCH_FILE); + const protocolDirectory = dirname(epochPath); + const runGit = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' }); + const runInFixture = (file, args, options) => + execFileSync(file, args, { ...options, cwd: repo, encoding: 'utf8' }); + + try { + runGit('init', '--initial-branch=main'); + runGit('config', 'user.email', 'epoch-guard@example.invalid'); + runGit('config', 'user.name', 'Epoch Guard Test'); + mkdirSync(protocolDirectory, { recursive: true }); + writeFileSync(epochPath, 'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 27 as const;\n'); + runGit('add', '.'); + runGit('commit', '-m', 'base epoch 27'); + runGit('tag', 'fork-point'); + runGit('branch', 'sibling-b'); + + writeFileSync(epochPath, 'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const;\n'); + writeFileSync(join(protocolDirectory, 'sibling-a.ts'), 'export const siblingA = true;\n'); + runGit('add', '.'); + runGit('commit', '-m', 'land sibling A at epoch 28'); + + runGit('switch', '--quiet', 'sibling-b'); + writeFileSync(epochPath, 'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const;\n'); + writeFileSync(join(protocolDirectory, 'sibling-b.ts'), 'export const siblingB = true;\n'); + runGit('add', '.'); + runGit('commit', '-m', 'prepare sibling B at epoch 28'); + + runGit('switch', '--quiet', 'main'); + runGit('merge', '--no-ff', 'sibling-b', '-m', 'synthetic merge'); + + const verdictAgainstForkPoint = evaluateEpochCheck({ + baseEpoch: epochAtRevision('fork-point', runInFixture), + headEpoch: epochAtRevision('HEAD', runInFixture), + changedProtocolFiles: changedProtocolFilesBetween('fork-point', 'HEAD', runInFixture), + }); + assert.equal(verdictAgainstForkPoint.ok, true); + + const verdictAgainstCurrentBase = evaluateEpochCheck({ + baseEpoch: epochAtRevision('HEAD^1', runInFixture), + headEpoch: epochAtRevision('HEAD', runInFixture), + changedProtocolFiles: changedProtocolFilesBetween('HEAD^1', 'HEAD', runInFixture), + }); + assert.equal(verdictAgainstCurrentBase.ok, false); + assert.match(verdictAgainstCurrentBase.reason, /still 28/); + assert.match(verdictAgainstCurrentBase.reason, /sibling-b\.ts/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + test('fails any epoch decrease, protocol change or not', () => { for (const changedProtocolFiles of [[], ['packages/runtime-host/src/protocol/index.ts']]) { const verdict = evaluateEpochCheck({ baseEpoch: 28, headEpoch: 27, changedProtocolFiles });