Skip to content
Merged
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
46 changes: 44 additions & 2 deletions scripts/check-regen-pending.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@
*/

import { execFileSync, execSync, spawnSync } from 'node:child_process';
import { appendFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand DownExpand Up@@ -383,7 +383,14 @@ function fixtureSelfTest() {
console.log(` ${cond ? '✓' : '✗'} ${label}`);
};

/** Run the real script inside the fixture, with the stub gate in the given state. */
/**
* Run the real script inside the fixture, with the stub gate in the given state.
*
* ⚠️ This WRITES `package.json` into the fixture worktree, and it is called while
* a merge is in progress. That is only safe because the stub is kept out of the
* index — see the `info/exclude` note below, and #9258 for what it cost when it
* was not.
*/
const runHook = (gate, args = []) => {
writeFileSync(
join(dir, 'package.json'),
Expand All@@ -406,6 +413,34 @@ function fixtureSelfTest() {
git(['config', 'user.name', 'os-regen fixture']);
git(['config', 'core.hooksPath', '/dev/null']); // the fixture drives the script itself
const gitDir = git(['rev-parse', '--absolute-git-dir']).trim();

// `package.json` here is the HARNESS's gate stub — `runHook` rewrites it to flip
// the stub `check:spec-changes` between passing and failing — not part of the
// two-commit scenario under test. It must therefore never enter the index, and
// this repo-local exclude is what keeps that true: the `git add -A` on `side2`
// below otherwise sweeps the stub into a commit, and from that point on every
// `runHook` call made DURING a merge leaves a tracked file whose stat data no
// longer matches the index entry the merge just recorded.
//
// `git merge --abort` is a `reset --merge`, which refuses to discard a tracked
// file that is not up to date — so the fixture crashed there, and crashed only
// SOMETIMES, because git compares mtime at one-second granularity: the abort
// survived exactly when the rewrite happened to land in the same wall-clock
// second as the merge's index write, and failed when it landed in the next one.
// The stub's CONTENT is identical either way; the file is only ever stat-dirty,
// which is why nothing in the fixture's own assertions could see it coming.
//
// Measured on #9258: two crashes in five CI runs across four PRs that never
// touched this script; reproduced 10/10 by delaying the rewrite past a second
// boundary, and 0/10 at that same delay with this exclude in place.
//
// `.git/info/exclude` and not a `.gitignore`: the latter would itself be a
// tracked file inside the merges under test, changing the scenario to protect
// the harness. `mkdirSync` because a custom `init.templateDir` need not ship
// `info/`, and a fixture that guards against flakiness may not add one.
mkdirSync(join(gitDir, 'info'), { recursive: true });
appendFileSync(join(gitDir, 'info', 'exclude'), '\n# the self-test gate stub (#9258) — never track it\npackage.json\n');

const marker = join(gitDir, PENDING_MARKER);
const pendingPath = 'packages/spec/spec-changes.json'; // a real REGEN_ARTIFACTS entry
const write = (f, c) => writeFileSync(join(dir, f), c);
Expand DownExpand Up@@ -452,6 +487,13 @@ function fixtureSelfTest() {
const second = runHook('stale');
check('a SECOND merge cannot defer on top of an outstanding deferral', second.code === 1);
check(' …so the exemption stays one commit deep', /one commit deep/.test(second.out));
// The invariant that keeps the abort below deterministic, asserted where it is
// load-bearing rather than left to the accident that used to hold it (#9258).
// Deliberately a state assertion and not a retry: if the stub is in the index,
// `merge --abort` fails on a coin flip, so only the state is reportable — the
// symptom is not. It covers every `git add` above, not just the one that broke.
check(' …with the gate stub still OUT of the index, so `merge --abort` cannot trip on it',
git(['ls-files', '--', 'package.json']).trim() === '');
git(['merge', '--abort']);

// The push is the other event that can follow a merge: it must not carry an
Expand Down
Loading