You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/21 13:39:13 UTC
(maka) branch main updated: ci: guard the compatibility epoch against silent same-number merges (#3339)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 4d223d05b ci: guard the compatibility epoch against silent same-number merges (#3339)
4d223d05b is described below
commit 4d223d05beea1bfa8af3b4bb4fe1211a9e93acfe
Author: Yihan Zhu <48...@users.noreply.github.com>
AuthorDate: Fri Aug 21 09:39:09 2026 -0400
ci: guard the compatibility epoch against silent same-number merges (#3339)
Compare the pull request synthetic merge with its current base parent so sibling protocol changes cannot silently reuse the same compatibility epoch.
Cover the collision with a real Git graph regression.
Generated-by: Codex
---
.github/workflows/ci.yml | 11 +++
scripts/protocol-epoch-check.mjs | 108 +++++++++++++++++++++++++++++
scripts/protocol-epoch-check.test.mjs | 127 ++++++++++++++++++++++++++++++++++
3 files changed, 246 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5a3fdcdb2..ba5a4d79d 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 000000000..f27c49a6b
--- /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 <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;
+ }
+}
diff --git a/scripts/protocol-epoch-check.test.mjs b/scripts/protocol-epoch-check.test.mjs
new file mode 100644
index 000000000..2a0a666a3
--- /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);
+ }
+});
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org