Skip to content

Commit ea6f2aa

Browse files
yihanzhuclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 98bdd6c commit ea6f2aa

3 files changed

Lines changed: 190 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,19 @@ jobs:
5050
- name: Check Windows test inventory
5151
run: npm run windows:inventory
5252

53+
# Runs on the PR merge result: after a sibling protocol change lands on
54+
# main with the same epoch text, the silently merged tree still carries
55+
# the base's epoch and this fails instead of shipping two incompatible
56+
# protocols under one number (#3313).
57+
- name: Guard the protocol compatibility epoch
58+
if: github.event_name == 'pull_request'
59+
env:
60+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
61+
run: node scripts/protocol-epoch-check.mjs --base "$BASE_SHA"
62+
63+
- name: Test the epoch guard
64+
run: node --test --test-concurrency=1 scripts/protocol-epoch-check.test.mjs
65+
5366
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
5467
if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true'
5568
with:

scripts/protocol-epoch-check.mjs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env node
2+
3+
// Merge-base guard for the Runtime Host compatibility epoch (#3313).
4+
//
5+
// Two branches that each bump the epoch write the same text to the same line,
6+
// so git's three-way merge resolves them without a conflict and two
7+
// incompatible protocols end up advertising one epoch. This check runs on the
8+
// PR merge result and fails when anything under the protocol directory changed
9+
// while the epoch still equals the merge base's — which is exactly the state a
10+
// silent same-number merge produces.
11+
12+
import { execFileSync } from 'node:child_process';
13+
import { dirname, resolve } from 'node:path';
14+
import { fileURLToPath } from 'node:url';
15+
16+
const scriptPath = fileURLToPath(import.meta.url);
17+
const defaultRepoRoot = dirname(dirname(scriptPath));
18+
19+
export const EPOCH_FILE = 'packages/runtime-host/src/protocol/index.ts';
20+
export const PROTOCOL_DIR = 'packages/runtime-host/src/protocol/';
21+
22+
const EPOCH_PATTERN = /^export const RUNTIME_HOST_COMPATIBILITY_EPOCH = (\d+) as const;$/gm;
23+
24+
export function extractCompatibilityEpoch(source) {
25+
const matches = [...source.matchAll(EPOCH_PATTERN)];
26+
if (matches.length !== 1) {
27+
throw new Error(
28+
`Expected exactly one RUNTIME_HOST_COMPATIBILITY_EPOCH declaration in ${EPOCH_FILE}, found ${matches.length}`,
29+
);
30+
}
31+
return Number(matches[0][1]);
32+
}
33+
34+
export function evaluateEpochCheck({ baseEpoch, headEpoch, changedProtocolFiles }) {
35+
if (headEpoch < baseEpoch) {
36+
return {
37+
ok: false,
38+
reason:
39+
`RUNTIME_HOST_COMPATIBILITY_EPOCH went backward: ${baseEpoch} -> ${headEpoch}. ` +
40+
`The epoch never decreases — a peer that saw ${baseEpoch} would admit an ` +
41+
`incompatible protocol. Bump it forward instead, even for a revert.`,
42+
};
43+
}
44+
if (changedProtocolFiles.length > 0 && headEpoch === baseEpoch) {
45+
return {
46+
ok: false,
47+
reason:
48+
`Protocol files changed but RUNTIME_HOST_COMPATIBILITY_EPOCH is still ${baseEpoch}, ` +
49+
`the merge base's value. Same-number bumps on sibling branches merge without a git ` +
50+
`conflict (#3313), so every protocol change must land with an epoch the merge base ` +
51+
`has not seen: rebase onto current main and set the epoch past ${baseEpoch}. ` +
52+
`Changed files:\n${changedProtocolFiles.map((file) => ` ${file}`).join('\n')}`,
53+
};
54+
}
55+
return {
56+
ok: true,
57+
reason:
58+
changedProtocolFiles.length > 0
59+
? `Protocol changed and the epoch moved: ${baseEpoch} -> ${headEpoch}.`
60+
: `No protocol changes against the merge base (epoch ${headEpoch}).`,
61+
};
62+
}
63+
64+
function git(args, exec = execFileSync) {
65+
return exec('git', args, { cwd: defaultRepoRoot, encoding: 'utf8' });
66+
}
67+
68+
export function changedProtocolFilesBetween(base, head, exec = execFileSync) {
69+
return git(['diff', '--no-renames', '--name-only', base, head, '--', PROTOCOL_DIR], exec)
70+
.split('\n')
71+
.filter(Boolean);
72+
}
73+
74+
export function epochAtRevision(revision, exec = execFileSync) {
75+
return extractCompatibilityEpoch(git(['show', `${revision}:${EPOCH_FILE}`], exec));
76+
}
77+
78+
function parseArgs(args) {
79+
const parsed = { base: undefined, head: 'HEAD' };
80+
for (let index = 0; index < args.length; index += 1) {
81+
if (args[index] === '--base') parsed.base = args[++index];
82+
else if (args[index] === '--head') parsed.head = args[++index];
83+
else throw new Error(`Unknown argument: ${args[index]}`);
84+
}
85+
if (!parsed.base) throw new Error('Expected --base <rev> (and optionally --head <rev>)');
86+
return parsed;
87+
}
88+
89+
function main(args) {
90+
const { base, head } = parseArgs(args);
91+
const verdict = evaluateEpochCheck({
92+
baseEpoch: epochAtRevision(base),
93+
headEpoch: epochAtRevision(head),
94+
changedProtocolFiles: changedProtocolFilesBetween(base, head),
95+
});
96+
process.stderr.write(`Protocol epoch guard: ${verdict.reason}\n`);
97+
if (!verdict.ok) process.exitCode = 1;
98+
}
99+
100+
if (process.argv[1] && resolve(process.argv[1]) === scriptPath) {
101+
try {
102+
main(process.argv.slice(2));
103+
} catch (error) {
104+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
105+
process.exitCode = 2;
106+
}
107+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
import {
4+
EPOCH_FILE,
5+
evaluateEpochCheck,
6+
extractCompatibilityEpoch,
7+
} from './protocol-epoch-check.mjs';
8+
import { readFileSync } from 'node:fs';
9+
import { dirname, join } from 'node:path';
10+
import { fileURLToPath } from 'node:url';
11+
12+
test('extracts the epoch from the declaration line', () => {
13+
assert.equal(
14+
extractCompatibilityEpoch('export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 27 as const;\n'),
15+
27,
16+
);
17+
});
18+
19+
test('refuses a source with no epoch declaration or more than one', () => {
20+
assert.throws(() => extractCompatibilityEpoch('export const OTHER = 1 as const;\n'), /found 0/);
21+
assert.throws(
22+
() =>
23+
extractCompatibilityEpoch(
24+
'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 27 as const;\n' +
25+
'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const;\n',
26+
),
27+
/found 2/,
28+
);
29+
});
30+
31+
test('parses the real protocol index, so the pattern cannot silently rot', () => {
32+
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
33+
const source = readFileSync(join(repoRoot, EPOCH_FILE), 'utf8');
34+
assert.equal(Number.isInteger(extractCompatibilityEpoch(source)), true);
35+
});
36+
37+
test('fails a protocol change whose epoch equals the merge base', () => {
38+
const verdict = evaluateEpochCheck({
39+
baseEpoch: 27,
40+
headEpoch: 27,
41+
changedProtocolFiles: ['packages/runtime-host/src/protocol/operations.ts'],
42+
});
43+
assert.equal(verdict.ok, false);
44+
assert.match(verdict.reason, /still 27/);
45+
assert.match(verdict.reason, /operations\.ts/);
46+
});
47+
48+
test('fails any epoch decrease, protocol change or not', () => {
49+
for (const changedProtocolFiles of [[], ['packages/runtime-host/src/protocol/index.ts']]) {
50+
const verdict = evaluateEpochCheck({ baseEpoch: 28, headEpoch: 27, changedProtocolFiles });
51+
assert.equal(verdict.ok, false);
52+
assert.match(verdict.reason, /went backward/);
53+
}
54+
});
55+
56+
test('passes a protocol change that moves the epoch forward', () => {
57+
const verdict = evaluateEpochCheck({
58+
baseEpoch: 27,
59+
headEpoch: 28,
60+
changedProtocolFiles: ['packages/runtime-host/src/protocol/index.ts'],
61+
});
62+
assert.equal(verdict.ok, true);
63+
});
64+
65+
test('passes when nothing under the protocol directory changed', () => {
66+
for (const headEpoch of [27, 28]) {
67+
const verdict = evaluateEpochCheck({ baseEpoch: 27, headEpoch, changedProtocolFiles: [] });
68+
assert.equal(verdict.ok, true);
69+
}
70+
});

0 commit comments

Comments
 (0)