landing-preconditions says where it is: a first line, a deadline on every child, a time on every step
A landing scan sat 10+ minutes in state S before printing step 1, with no build under it, and the log could not say what it was waiting on. Now: - the first line is written before any child starts (pid, node version); - every child gets an empty stdin and a deadline (git 3 minutes, the freshness rebuild 20), and a timeout REFUSES by name, with the elapsed time and a pointer to main-capture diag, instead of holding the lock; - each step prints how long the scan has run.
This commit is contained in:
parent
16f2d51531
commit
a89c4f2e72
1 changed files with 34 additions and 16 deletions
|
|
@ -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=<name> 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.')
|
||||
|
|
|
|||
Loading…
Reference in a new issue