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