* fix(3582): stop the cold-tree fixture racing concurrent hook builds Two tests in tests/gsd-check-update-worker-platform-gate.test.cjs failed a verification run with `ENOENT: no such file or directory, lstat '/work/hooks/.dist-staging-20836'`. This is a race I introduced in #3582, not a flake, and it passed when #3582 merged because it only fires when the timing lines up. buildColdInstallTree() copied the LIVE repo hooks/ directory with a filter that excluded only the basename 'dist'. scripts/build-hooks.js writes atomically through a per-PID staging dir, hooks/.dist-staging-<pid>, and removes it when finished — and the archived build-hooks-atomic-write changeset records that NINE test files invoke build-hooks.js from their before() hooks. So several test processes create and delete staging directories inside hooks/ while other tests are reading it. cpSync enumerated one, and the owning process removed it before cpSync got to it. The helper's own header already states the rule it needed: hooks/dist is excluded because it "is not present in a raw marketplace checkout either". hooks/.dist-staging-* is gitignored (.gitignore:21) and equally absent from a raw checkout — it was simply missed. Fixed by enumerating hooks/ explicitly and skipping 'dist' and any '.dist-staging' prefix BY NAME, before anything stats or copies the entry, then copying each surviving entry individually. A name-first skip means a vanishing staging dir is never touched at all. Worth recording because it corrects the assumption this fix was written under: cpSync's filter IS invoked before the entry is lstat'd, and returning false leaves it untouched (verified by deleting inside the callback and returning false — no throw). So merely adding '.dist-staging' to the old filter would also have closed the race. The explicit enumeration was kept anyway so correctness does not depend on that Node implementation detail. Proven by execution both ways: with a staging dir planted in hooks/, the OLD cpSync-with-filter form copied it straight through into the fixture, while the new form succeeds and produces no .dist-staging entry with the real hook set intact. Regression test added beside the existing cold-tree tests: it plants a real hooks/.dist-staging-test-<random>, asserts the fixture builds clean without it, and removes only the directory it created. Repo swept for the same exposure: this helper is the only place doing a bulk enumeration of the whole live hooks/ tree. The other hooks/-touching tests reference specific named files or hooks/dist/ and are not exposed. scripts/build-hooks.js is deliberately untouched — its per-PID staging is what makes its own writes atomic and is correct. Refs #3582 * fix(3582): make the race regression test hermetic instead of mutating the live tree The regression test added in the previous commit failed the runner with "failed running after hook", and it was wrong in two ways — the second one worse than the first. cleanup() (tests/helpers.cjs:452-487) deliberately THROWS for any path outside the known temp roots. The test planted hooks/.dist-staging-test-<random> inside the repo and then asked cleanup() to remove it, so the after-hook threw. That guard is correct and is left alone. The real problem is that the test mutated the LIVE hooks/ directory while other test files concurrently read it — the exact shared-state hazard this change exists to remove. A regression test for a race must not introduce one. buildColdInstallTree now takes an optional opts.repoRoot (defaulting to the real REPO_ROOT and used for both copies it performs), so the test builds a fake repo root under the temp dir, plants representative hooks plus dist/ and .dist-staging-99999/ THERE, and asserts the fixture excludes both. All six pre-existing callers pass no arguments and are unaffected. The test also asserts the real hooks/ listing is identical before and after, so a future edit that reintroduces live-tree mutation fails loudly. The name rule is now pinned directly rather than only through the copy. shouldCopyHookEntry is exported and asserted, including the two cases a sloppier implementation would get wrong: 'dist-staging-no-dot' and 'distant.js' must both be KEPT. Anything matching on a loose 'dist' substring or startsWith passes every other case and fails those two. Also corrected the issue number on the tests introduced here: they were labelled #3631, which is the unrelated capability-consent bytecode work. This is #3582. Verified by execution: the predicate rule holds on all nine cases; a fake-root fixture yields exactly the representative hooks with dist and .dist-staging excluded; the no-arg default still copies the real tree (29 entries); and the real hooks/ listing is byte-identical before and after. Refs #3582 * chore(3582): re-trigger CI after an orphaned Validate Branch Name run The Validate Branch Name run for this branch (32211622051) sat queued from 03:17 and was never picked up — updatedAt never advanced past createdAt while the same workflow completed normally for other branches. `gh run rerun` refused it ("already running") and `gh run cancel` returned HTTP 500, so the run is orphaned on the GitHub side. Closing and reopening the PR re-fired the other pull_request workflows but not that one, whose triggers evidently do not include reopened. An empty commit is the remaining way to get a fresh run. No file changes: the tree is identical to dc71534b6, whose remote-runner pass carries forward unchanged. Recording this rather than admin-merging past the pending check. Everything else was green (24 pass, 0 fail), but admin merge is sanctioned only for the missing-secondary-reviewer case, never to skip a gate that has not actually run. Refs #3582 --------- Co-authored-by: sim <sim@local>
103 lines
4.8 KiB
JavaScript
103 lines
4.8 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Build a hermetic "cold tree" install fixture for #3582 — a copy of hooks/
|
|
* plus gsd-core/bin/ensure-runtime-build.cjs with the compiled
|
|
* gsd-core/bin/lib/*.cjs directory and tsconfig.build.json deliberately
|
|
* ABSENT, mirroring a raw plugin-marketplace / git-clone install that never
|
|
* ran `npm run build:lib`.
|
|
*
|
|
* Deliberately NOT `tests/helpers/copy-script-fixture.cjs`'s
|
|
* `copyScriptWithDeps`: that helper walks the require graph and copies every
|
|
* dependency it finds — including gsd-core/bin/lib/*.cjs, which exist in
|
|
* THIS repo's already-built tree — so it would faithfully reproduce a WARM
|
|
* tree, the opposite of what a cold-tree test needs. This helper copies only
|
|
* hooks/ and the seam module itself, and never touches gsd-core/bin/lib/ or
|
|
* tsconfig.build.json — so `ensureRuntimeBuild()` inside the fixture
|
|
* deterministically throws `RuntimeBuildError` ("tsconfig.build.json not
|
|
* found") the first time any fixture hook reaches it, without ever deleting
|
|
* or touching the real repo's gsd-core/bin/lib/.
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
|
|
/**
|
|
* Pure name-filter decision for a single hooks/ top-level entry: should it be
|
|
* copied into the cold-tree fixture? Excludes the build output dir and any
|
|
* transient build-hooks.js staging dir (both by NAME ONLY — see the
|
|
* no-stat-on-a-skipped-name rationale in buildColdInstallTree below).
|
|
* @param {string} name
|
|
* @returns {boolean}
|
|
*/
|
|
function shouldCopyHookEntry(name) {
|
|
return name !== 'dist' && !name.startsWith('.dist-staging');
|
|
}
|
|
|
|
/**
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.repoRoot] TEST-ONLY: source tree root to copy hooks/
|
|
* and gsd-core/bin/ensure-runtime-build.cjs from, in place of the real
|
|
* REPO_ROOT. Lets a test point this fixture at a hermetic fake tree instead
|
|
* of mutating the live repo's hooks/ directory (which other test files may
|
|
* be concurrently reading). Defaults to REPO_ROOT.
|
|
* @returns {{ dir: string, hooksDir: string, cleanup: () => void }}
|
|
*/
|
|
function buildColdInstallTree(opts = {}) {
|
|
const repoRoot = opts.repoRoot || REPO_ROOT;
|
|
const dir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-cold-tree-'));
|
|
|
|
// hooks/ — entire directory (top-level hook scripts + hooks/lib/*.js +
|
|
// managed-hooks-registry.cjs + hooks.json). hooks/dist/ (gitignored,
|
|
// build-hooks.js output) is excluded — it is not present in a raw
|
|
// marketplace checkout either.
|
|
//
|
|
// This is NOT a single recursive fs.cpSync(hooks/, ...) with a `filter`
|
|
// callback. The live hooks/ directory is not stable during a test run:
|
|
// scripts/build-hooks.js writes atomically via a per-PID staging dir
|
|
// (hooks/.dist-staging-<pid>, gitignored — see .gitignore:21) that it
|
|
// creates and then removes once the atomic rename into hooks/dist/ is
|
|
// done, and up to nine test files invoke build-hooks.js concurrently from
|
|
// their `before()` hooks. A recursive cpSync enumerates hooks/, may
|
|
// observe another process's transient .dist-staging-<pid> entry, and can
|
|
// then lstat/copy it AFTER that process has already deleted it — an
|
|
// intermittent ENOENT ("no such file or directory, lstat
|
|
// '.../hooks/.dist-staging-<pid>'"). Excluding only the basename 'dist'
|
|
// does not help: '.dist-staging-<pid>' is a different name.
|
|
//
|
|
// Fix: enumerate hooks/ ourselves and skip transient entries BY NAME
|
|
// ONLY, before ever touching them — no stat/lstat on a name we're going
|
|
// to skip. Skipping by name (rather than filtering after readdir handed
|
|
// control to fs.cpSync's own traversal) is what closes the race: a
|
|
// vanishing .dist-staging-<pid> dir is never accessed at all once its
|
|
// name has excluded it.
|
|
const hooksDestDir = path.join(dir, 'hooks');
|
|
fs.mkdirSync(hooksDestDir, { recursive: true });
|
|
for (const entry of fs.readdirSync(path.join(repoRoot, 'hooks'), { withFileTypes: true })) {
|
|
if (!shouldCopyHookEntry(entry.name)) continue;
|
|
fs.cpSync(path.join(repoRoot, 'hooks', entry.name), path.join(hooksDestDir, entry.name), {
|
|
recursive: true,
|
|
});
|
|
}
|
|
|
|
// gsd-core/bin/ensure-runtime-build.cjs — the seam itself. Deliberately
|
|
// NOT gsd-core/bin/lib/ (absent — isBuilt() reads false) and NOT
|
|
// tsconfig.build.json at the fixture root (absent — ensureRuntimeBuild's
|
|
// "cannot auto-build" branch fires deterministically).
|
|
fs.mkdirSync(path.join(dir, 'gsd-core', 'bin'), { recursive: true });
|
|
fs.copyFileSync(
|
|
path.join(repoRoot, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'),
|
|
path.join(dir, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'),
|
|
);
|
|
|
|
function cleanup() {
|
|
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
}
|
|
|
|
return { dir, hooksDir: path.join(dir, 'hooks'), cleanup };
|
|
}
|
|
|
|
module.exports = { buildColdInstallTree, REPO_ROOT, shouldCopyHookEntry };
|