blob: db4dc965180e1b212d042789a5deecc2a57326c3 [file]
#!/usr/bin/env node
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// 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. An incompatible change must move the epoch. A compatible
// extension may keep it only when a newly added declaration names every changed
// protocol file, keeping that exception explicit and reviewable.
import { execFileSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyHeader, classifyPath } from './asf-license-headers.mjs';
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/';
export const COMPATIBLE_CHANGE_DIR = 'packages/runtime-host/protocol-compatible-changes/';
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,
compatibleProtocolFiles = [],
}) {
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.`,
};
}
const compatible = new Set(compatibleProtocolFiles);
const incompatibleChanges = changedProtocolFiles.filter((file) => !compatible.has(file));
if (incompatibleChanges.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 without a compatible-change declaration:\n` +
`${incompatibleChanges.map((file) => ` ${file}`).join('\n')}`,
};
}
return {
ok: true,
reason:
changedProtocolFiles.length > 0
? headEpoch === baseEpoch
? `Protocol added a declared compatible extension at epoch ${headEpoch}.`
: `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 compatibleProtocolFilesBetween(base, head, headEpoch, exec = execFileSync) {
const declarations = git(
['diff', '--diff-filter=A', '--name-only', base, head, '--', COMPATIBLE_CHANGE_DIR],
exec,
)
.split('\n')
.filter(Boolean);
const compatibleFiles = new Set();
for (const declaration of declarations) {
const value = JSON.parse(git(['show', `${head}:${declaration}`], exec));
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
value.epoch !== headEpoch ||
!Array.isArray(value.files) ||
value.files.length === 0 ||
typeof value.reason !== 'string' ||
value.reason.trim().length === 0 ||
Object.keys(value).some((key) => !['epoch', 'files', 'reason'].includes(key))
) {
throw new Error(`Invalid compatible protocol change declaration: ${declaration}`);
}
for (const file of value.files) {
if (typeof file !== 'string' || !file.startsWith(PROTOCOL_DIR)) {
throw new Error(`Invalid protocol file in compatible change declaration: ${declaration}`);
}
compatibleFiles.add(file);
}
}
return [...compatibleFiles];
}
export function epochAtRevision(revision, exec = execFileSync) {
return extractCompatibilityEpoch(git(['show', `${revision}:${EPOCH_FILE}`], exec));
}
/**
* Whether a file changed only by gaining the ASF license header.
*
* The guard's question is whether the protocol changed. "A file under the
* protocol directory was touched" is a conservative proxy for that, and the
* asymmetry justifies it: a needless epoch bump costs a number, a missed one
* ships two incompatible protocols under one. But inserting a license header
* provably does not change the protocol, and answering that with a bump would
* tell every peer the wire is incompatible over a comment.
*
* The test is `applyHeader`, the same authority that writes the headers, so
* this exempts exactly the canonical insertion and nothing that resembles it.
* A file that gained a header *and* a real edit fails the comparison and still
* requires an epoch.
*
* Everything else is a protocol change. A file that exists on only one side —
* added, deleted, or one half of a rename, since the diff is taken with
* `--no-renames` — has no pair to compare and is one by definition, so the
* failing `git show` resolves to `false` rather than escaping. Every read is
* inside the guard for that reason: the exemption has to fail toward requiring
* an epoch, never toward crashing the check that would have demanded one.
*/
export function isHeaderOnlyChange(file, base, head, exec = execFileSync) {
const style = classifyPath(file).style;
if (!style) return false;
try {
const before = git(['show', `${base}:${file}`], exec);
const after = git(['show', `${head}:${file}`], exec);
return applyHeader(before, style) === after;
} catch {
return false;
}
}
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 headEpoch = epochAtRevision(head);
const verdict = evaluateEpochCheck({
baseEpoch: epochAtRevision(base),
headEpoch,
changedProtocolFiles: changedProtocolFilesBetween(base, head).filter(
(file) => !isHeaderOnlyChange(file, base, head),
),
compatibleProtocolFiles: compatibleProtocolFilesBetween(base, head, headEpoch),
});
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;
}
}