diff --git a/internal/landing-preconditions.mjs b/internal/landing-preconditions.mjs index 8f0752037..27a7a9d60 100644 --- a/internal/landing-preconditions.mjs +++ b/internal/landing-preconditions.mjs @@ -26,6 +26,11 @@ * node internal/landing-preconditions.mjs --markers-self-test proves step 0 refuses a conflict * DOXA_SKIP_FRESHNESS=1 skips (4) — for a DRY check only; main-capture scan never sets it. */ +// ★ STEP 0 PRINTS BEFORE ANYTHING CAN WAIT (2026-09-29). A scan once sat 10+ minutes in state S before +// printing step 1, with no build running under it, and the log could not say where. So the first line +// is written before any child is started, every child has a deadline, and each step prints its time. +// ESM hoists imports, so this is the earliest this module can speak; the imports below read no disk. +process.stdout.write(`LANDING-PRECONDITIONS: start — pid ${process.pid}, node ${process.version}\n`) import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' @@ -40,13 +45,26 @@ const refuse = (why, detail = '') => { if (detail) console.error(detail.split('\n').map((l) => ' ' + l).join('\n')) process.exit(1) } -const run = (cmd, args) => spawnSync(cmd, args, { cwd: ROOT, encoding: 'utf8', env: process.env }) +// Every child gets a deadline and an EMPTY stdin: a child that waits on its input (or on a lock) fails +// loudly, by name, instead of holding the landing lock with nothing in the log. 20 minutes covers the +// freshness rebuild, the one step that legitimately takes that long; git steps get 3. +const T0 = Date.now() +const since = () => `${((Date.now() - T0) / 1000).toFixed(1)}s` +function run(cmd, args, timeoutMs = 20 * 60 * 1000) { + const r = spawnSync(cmd, args, { cwd: ROOT, encoding: 'utf8', env: process.env, input: '', timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024 }) + if (r.error && r.error.code === 'ETIMEDOUT') { + refuse(`${cmd} ${args.slice(0, 3).join(' ')} did not finish in ${Math.round(timeoutMs / 1000)}s`, + `It was killed at ${since()}. If this repeats, run main-capture diag on the child before it is killed.`) + } + return r +} +const git = (args) => run('git', args, 3 * 60 * 1000) // 0. NO MERGE MARKERS — scanned in the COMMIT's tree (git grep), so public/ and every file is covered, // whatever the working tree holds. Markers are built from char codes so this file never contains one. const OPEN = String.fromCharCode(60).repeat(7), MID = String.fromCharCode(61).repeat(7), CLOSE = String.fromCharCode(62).repeat(7) function markerFiles(ref) { - const g = run('git', ['grep', '-I', '-l', '-E', '-e', `^(${OPEN}( |$)|${MID}$|${CLOSE}( |$))`, ref, '--']) + const g = git(['grep', '-I', '-l', '-E', '-e', `^(${OPEN}( |$)|${MID}$|${CLOSE}( |$))`, ref, '--']) if (g.status > 1) refuse('could not scan the commit for merge markers', g.stderr.trim()) return g.stdout.split('\n').filter(Boolean).map((l) => l.replace(new RegExp('^' + ref + ':'), '')) } @@ -64,18 +82,18 @@ if (process.argv.includes('--markers-self-test')) { const markers = markerFiles('HEAD') if (markers.length) refuse(`${markers.length} file(s) in this commit hold merge-conflict markers`, markers.slice(0, 10).join('\n') + '\nFor a BUILT file: take either side, REBUILD it, commit the rebuild. Never `git add -A public` mid-merge.') -console.log('LANDING-PRECONDITIONS: 0 markers — no file in the commit holds a merge-conflict marker') +console.log(`LANDING-PRECONDITIONS: 0 markers — no file in the commit holds a merge-conflict marker (${since()})`) // 1. CLEAN -const dirty = run('git', ['status', '--porcelain', '--', 'public', 'plugin-designs', 'library', 'internal', 'vite.config.js', 'vite.doxa-maps-build-config.json', 'package.json']) +const dirty = git(['status', '--porcelain', '--', 'public', 'plugin-designs', 'library', 'internal', 'vite.config.js', 'vite.doxa-maps-build-config.json', 'package.json']) if (dirty.status !== 0) refuse('could not read git status') if (dirty.stdout.trim()) refuse('the working tree differs from the commit being landed — the checks would judge files that are not in it', dirty.stdout.trim().split('\n').slice(0, 8).join('\n')) -console.log('LANDING-PRECONDITIONS: 1 clean — the working tree is the commit being landed') +console.log(`LANDING-PRECONDITIONS: 1 clean — the working tree is the commit being landed (${since()})`) // 2. ONE FOLDER, and it is the configured one // What LANDS is the commit, so read public/ from HEAD, not from the disk: a gitignored, untracked leftover // (a pre-rename public/assets/ in an older worktree) refused a clean README-only landing (repo, 2026-09-25). -const tree = run('git', ['ls-tree', '--name-only', 'HEAD', 'public/']) +const tree = git(['ls-tree', '--name-only', 'HEAD', 'public/']) if (tree.status !== 0) refuse('could not read public/ from HEAD') const entries = tree.stdout.split('\n').filter(Boolean).map((l) => l.replace(/^public\//, '')).sort() const want = ['README.md', TREE_NAME].sort() @@ -84,19 +102,19 @@ if (JSON.stringify(entries) !== JSON.stringify(want)) { refuse(`public/ must hold exactly ${want.join(' + ')} (the tree name comes from vite.doxa-maps-build-config.json)`, [extra.length ? `extra: ${extra.join(', ')} — e.g. a build made under an OLD tree name writes its old folder` : '', missing.length ? `missing: ${missing.join(', ')}` : ''].filter(Boolean).join('\n')) } -console.log(`LANDING-PRECONDITIONS: 2 one folder — public/ = README.md + ${TREE_NAME}/`) +console.log(`LANDING-PRECONDITIONS: 2 one folder — public/ = README.md + ${TREE_NAME}/ (${since()})`) // 3. SRI const sri = run(process.execPath, ['internal/sri-consistency-check.mjs']) if (sri.status !== 0) refuse('check:sri failed', (sri.stdout + sri.stderr).trim().split('\n').slice(-6).join('\n')) -console.log('LANDING-PRECONDITIONS: 3 sri — every leaf loads the bundle its integrity names') +console.log(`LANDING-PRECONDITIONS: 3 sri — every leaf loads the bundle its integrity names (${since()})`) // 3b. MOUNTS — every capability-4/5 composite mounts headless in en and ar without a setup/watcher/mount // error (check:composites-mount, ~20 s). 08f89f55 left Big Picture and Simple Research BLANK while every // other check passed; a landing that blanks a composite is now refused. const mounts = run(process.execPath, ['internal/check-composites-mount.mjs']) if (mounts.status !== 0) refuse('a composite no longer mounts (check:composites-mount)', (mounts.stdout + mounts.stderr).trim().split('\n').filter((l) => /FAIL|✗|Error/.test(l)).slice(0, 8).join('\n')) -console.log('LANDING-PRECONDITIONS: 3b mounts — every composite mounts in en and ar') +console.log(`LANDING-PRECONDITIONS: 3b mounts — every composite mounts in en and ar (${since()})`) // 4. FRESH if (process.env.DOXA_SKIP_FRESHNESS === '1') { @@ -106,15 +124,15 @@ if (process.env.DOXA_SKIP_FRESHNESS === '1') { // dependencies) reaches ANY bundle — measured 2026-09-25: one Arabic catalogue edit in library/i18n left 12 // of 25 bundles stale, most of them not composites, and a composite-only check saw none of it. Otherwise: // every composite, plus each bundle whose own folder the landing touched. - const base = run('git', ['merge-base', 'HEAD', `origin/${process.env.MAIN_CAPTURE_BRANCH || process.env.LANDING_BRANCH || 'development'}`]).stdout.trim() - const changed = base ? run('git', ['diff', '--name-only', base, 'HEAD']).stdout.split('\n').filter(Boolean) : [] + const base = git(['merge-base', 'HEAD', `origin/${process.env.MAIN_CAPTURE_BRANCH || process.env.LANDING_BRANCH || 'development'}`]).stdout.trim() + const changed = base ? git(['diff', '--name-only', base, 'HEAD']).stdout.split('\n').filter(Boolean) : [] // ★ A library/ change no longer means EVERY bundle. It means every bundle whose SOURCE GRAPH contains a // changed file, or that reads a changed i18n area: measured, a one-file library fix reached 1 of 24 // bundles, and rebuilding all 24 cost ~10 min per landing round. Only the build itself (config, // dependencies, the tree generator) can reach every bundle, so only that still means --all. const pkgDepsChanged = () => { if (!changed.includes('package.json') || !base) return false - const deps = (ref) => { try { const j = JSON.parse(run('git', ['show', `${ref}:package.json`]).stdout); return JSON.stringify([j.dependencies, j.devDependencies]) } catch { return '' } } + const deps = (ref) => { try { const j = JSON.parse(git(['show', `${ref}:package.json`]).stdout); return JSON.stringify([j.dependencies, j.devDependencies]) } catch { return '' } } return deps(base) !== deps('HEAD') } // The build config reaches every bundle only through a key the BUILD reads. dropin (where sync-dropin copies @@ -124,7 +142,7 @@ if (process.env.DOXA_SKIP_FRESHNESS === '1') { if (!changed.includes(CFG) || !base) return false const keys = (ref) => { try { - const j = JSON.parse(run('git', ['show', `${ref}:${CFG}`]).stdout) + const j = JSON.parse(git(['show', `${ref}:${CFG}`]).stdout) for (const k of Object.keys(j)) if (k === 'dropin' || k.startsWith('//')) delete j[k] return JSON.stringify(j) } catch { return null } @@ -154,8 +172,8 @@ if (process.env.DOXA_SKIP_FRESHNESS === '1') { // scope, so a PASS is recorded against both; the landing script runs this precondition BEFORE taking the // lock, and the scan inside the lock finds the record and does not rebuild again. A different tree or a // wider scope has no record and is checked in full. The record lives in the repo's own git dir. - const treeId = run('git', ['rev-parse', 'HEAD^{tree}']).stdout.trim() - const stampDir = resolve(ROOT, run('git', ['rev-parse', '--git-common-dir']).stdout.trim(), 'doxa-fresh') + const treeId = git(['rev-parse', 'HEAD^{tree}']).stdout.trim() + const stampDir = resolve(ROOT, git(['rev-parse', '--git-common-dir']).stdout.trim(), 'doxa-fresh') // The scope can be a long bundle list now, and a filename has a length limit: hash it. const scopeKey = scope.length ? createHash('sha1').update(scope.join(' ')).digest('hex').slice(0, 16) : 'composites' const stamp = resolve(stampDir, `${treeId}.${scopeKey}`) @@ -173,6 +191,6 @@ if (process.env.DOXA_SKIP_FRESHNESS === '1') { 'own plugin is stale, rebuild just it: BUNDLE= bun x vite build && bun run tree && bun run fix:sri.', (fresh.stdout + fresh.stderr).trim().split('\n').filter((l) => /✗|STALE|FAIL/.test(l)).slice(0, 8).join('\n')) console.log('LANDING-PRECONDITIONS: 4 fresh — every composite rebuilds to its committed bytes') - if (treeId) { mkdirSync(stampDir, { recursive: true }); writeFileSync(stamp, `${new Date().toISOString()} (commit ${run('git', ['rev-parse', '--short', 'HEAD']).stdout.trim()})\n`) } + if (treeId) { mkdirSync(stampDir, { recursive: true }); writeFileSync(stamp, `${new Date().toISOString()} (commit ${git(['rev-parse', '--short', 'HEAD']).stdout.trim()})\n`) } } console.log('LANDING-PRECONDITIONS: PASS — this commit may land.')