Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
108 changes: 108 additions & 0 deletions scripts/protocol-epoch-check.mjs
Original file line number Diff line number Diff line change
@@ -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 <rev> (and optionally --head <rev>)');
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;
}
}
127 changes: 127 additions & 0 deletions scripts/protocol-epoch-check.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});