diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a3fdcdb29..ba5a4d79d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,17 @@ 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 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' + 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 + - 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..f27c49a6ba --- /dev/null +++ b/scripts/protocol-epoch-check.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +// 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 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'; +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 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')}`, + }; + } + return { + ok: true, + reason: + changedProtocolFiles.length > 0 + ? `Protocol changed and the epoch moved: ${baseEpoch} -> ${headEpoch}.` + : `No protocol changes against the current base parent (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..2a0a666a39 --- /dev/null +++ b/scripts/protocol-epoch-check.test.mjs @@ -0,0 +1,127 @@ +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 { 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 current base parent', () => { + 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('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 }); + 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); + } +});