From aa139a5114351e0032cfe300e9a9275bf11753a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 08:15:40 +0000 Subject: [PATCH 1/6] ci(gates): add the lane verdict that keeps a continue-on-error lane red Claude-Session: https://claude.ai/code/session_012zwsLMxQBwHUYJgPyrpEvx Co-authored-by: Claude --- scripts/ci/lane-verdict.mjs | 591 ++++++++++++++++++++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 scripts/ci/lane-verdict.mjs diff --git a/scripts/ci/lane-verdict.mjs b/scripts/ci/lane-verdict.mjs new file mode 100644 index 00000000000..9a0c3f34227 --- /dev/null +++ b/scripts/ci/lane-verdict.mjs @@ -0,0 +1,591 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// lane-verdict -- the one step that keeps a gate lane's red (#23191). +// +// node scripts/ci/lane-verdict.mjs # in-job: the LAST step of a gate lane +// node scripts/ci/lane-verdict.mjs --self-test # offline, fixtures only, no env needed +// +// ## What this is for +// +// Every gate step of a `Lint · *` and a `Type Check · *` lane in +// `.github/workflows/lint.yml` runs with `continue-on-error: true` (#23177 +// ruling B, item A1, comment 6139629628), so a red gate no longer skips every +// gate behind it: one run reports every red in the lane instead of the first. +// +// `continue-on-error` on its own trades that red for silence. GitHub records +// two results per step -- `outcome`, before `continue-on-error` is applied, and +// `conclusion`, after it -- and for a failed step under the flag they DIFFER: +// `outcome` is `failure`, `conclusion` is `success`, and the job goes on to +// conclude `success`. This step is what puts the red back. It reads the lane's +// gate roster out of the workflow file, reads each gate's OUTCOME from the +// `steps` context, prints the red ones by name and exits 1, so the lane -- and +// the required aggregate that reads the lane's `result` -- still concludes +// `failure`. ⛔ Never read `conclusion` here: under the flag it is `success` for +// every gate, red or green, and the verdict would be green forever. +// +// ## Inputs, all from the step that runs it +// +// OS_LANE_STEPS `${{ toJSON(steps) }}` -- the `steps` context. It holds +// only steps that carry an `id:`, which is why every gate +// step carries one. +// OS_LANE_JOB_STATUS `${{ job.status }}` -- `success` unless a step WITHOUT +// `continue-on-error` failed (checkout, setup, install, +// the gate-family selector, a workspace build): those +// stay fail-fast, so their failure means the gates behind +// them never ran. +// GITHUB_JOB the job id (`lint-repo-tooling`, `typecheck-debt`, ...) +// GITHUB_WORKFLOW_REF `owner/repo/@`; the path names the workflow +// file in the checkout, which is the file the runner is +// executing (the checkout is the run's own commit). +// +// ## The roster is read from the WORKFLOW, never from the `steps` context +// +// The context only knows steps that have an id. A gate step added with +// `continue-on-error: true` and no `id:` would be absent from it, and a verdict +// that iterated the context would never see that gate -- its red would be the +// silence this file exists to remove. So the roster is every step of THIS job +// whose `continue-on-error` is set, read from the workflow, and a roster entry +// with no id, or with no outcome in the context, is red ("unread"), never a +// pass. A step without `continue-on-error` is not a gate here: its failure +// fails the job on its own, and `OS_LANE_JOB_STATUS` says so. +// +// ## Skipped is two different facts +// +// A gate the run never reached and a gate its own `if:` turned off (the +// gate-family selector's scoped steps) both read `skipped`. They are told +// apart by the two facts that are certain: whether the gate HAS an `if:`, and +// whether the job is still `success`. Only a gate with an `if:` in a job that +// is still `success` counts as turned off by its own condition; every other +// `skipped` is "did not run", which is red. +// +// ## Exit codes +// +// 0 every gate in the roster ran green or was turned off by its own `if:`, +// and no fail-fast step failed. +// 1 a FINDING: a gate is red, did not run, or cannot be read; or a fail-fast +// step failed (the job is red already; this names what it hid). +// 3 PREREQUISITE NOT MET -- an input above is absent or unparseable, so +// nothing was judged. On a runner that is still a red step, which is the +// point: a verdict that cannot read its lane has not passed it. Run +// locally, the bare form always lands here; `--self-test` is the local +// instrument. +// +// ## What it deliberately does NOT do +// +// * It changes no gate's command and judges nothing about the tree. A gate's +// own output is in its own step's log; this prints only which ones failed. +// * It does not call the jobs API. The `steps` context is final for every +// step before this one when this runs, which the API is not (the reporter +// this replaces disclosed NOT MEASURED on 24 of 42 red jobs for that reason). +// * It writes the job summary only when the verdict is not green, so a green +// lane's summary page stays as it was. + +import { appendFileSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { EXIT_FINDINGS, EXIT_PREREQUISITE_NOT_MET, requireDependency } from '../import-prerequisite.mjs'; +import { isEntrypoint } from '../invoked-as.mjs'; + +const SCRIPT = 'lane-verdict'; +const REPO_ROOT = resolve(fileURLToPath(new URL('../..', import.meta.url))); + +/** A refusal: an input this verdict needs is absent or unreadable. */ +class LaneVerdictRefusal extends Error {} + +/** + * `continue-on-error` is set when it is anything but absent or false. An + * expression (`${{ … }}`) counts as set: whether it evaluates true decides the + * step's `conclusion`, never its `outcome`, and the outcome is what is read. + * + * @param {unknown} value + */ +export function continuesOnError(value) { + if (value === undefined || value === null || value === false) return false; + return String(value).trim().toLowerCase() !== 'false'; +} + +/** + * The workflow path out of `GITHUB_WORKFLOW_REF` (`owner/repo/@`). + * + * @param {string|undefined} ref + * @returns {string|null} + */ +export function workflowPathFromRef(ref) { + const m = /^[^/@\s]+\/[^/@\s]+\/([^@\s]+)@\S+$/.exec(String(ref ?? '').trim()); + return m ? m[1] : null; +} + +/** + * One job's steps, as the workflow declares them, from a parsed workflow + * document. Throws a refusal when the job or its step list is not there. + * + * @param {Record} doc the parsed workflow + * @param {string} jobId + * @returns {Array<{ position: number, name: string, id: string|null, cond: string|null, gate: boolean }>} + */ +export function laneSteps(doc, jobId) { + const job = doc?.jobs?.[jobId]; + if (!job || typeof job !== 'object') { + throw new LaneVerdictRefusal(`the workflow declares no job \`${jobId}\`, so there is no roster to read`); + } + if (!Array.isArray(job.steps) || job.steps.length === 0) { + throw new LaneVerdictRefusal(`job \`${jobId}\` declares no \`steps:\` list, so there is no roster to read`); + } + return job.steps.map((step, i) => { + const s = step && typeof step === 'object' ? step : {}; + const name = + typeof s.name === 'string' && s.name.trim() !== '' + ? s.name.trim() + : typeof s.uses === 'string' + ? `(unnamed step: uses ${s.uses})` + : `(unnamed step #${i + 1})`; + const id = typeof s.id === 'string' && s.id.trim() !== '' ? s.id.trim() : null; + const cond = s.if === undefined || s.if === null || String(s.if).trim() === '' ? null : String(s.if).trim(); + return { position: i + 1, name, id, cond, gate: continuesOnError(s['continue-on-error']) }; + }); +} + +/** + * The verdict, as a pure function of the roster, the `steps` context and the + * job status. Never throws: an unreadable input is a `refusal`, which is + * rendered and exits PREREQUISITE NOT MET. + * + * @param {{ steps: ReturnType, context: unknown, jobStatus: string|undefined }} input + */ +export function judgeLane({ steps, context, jobStatus }) { + const result = { + refusal: null, + gates: [], + red: [], + didNotRun: [], + unread: [], + skippedByCondition: [], + passed: [], + failFast: false, + jobStatus: String(jobStatus ?? ''), + ok: false, + }; + const status = result.jobStatus.trim(); + if (status === '') { + result.refusal = 'OS_LANE_JOB_STATUS is empty, so whether a fail-fast step failed cannot be read'; + return result; + } + if (!context || typeof context !== 'object' || Array.isArray(context)) { + result.refusal = 'OS_LANE_STEPS is not a JSON object, so no gate outcome can be read'; + return result; + } + const roster = (Array.isArray(steps) ? steps : []).filter((s) => s.gate); + if (roster.length === 0) { + result.refusal = + 'this job declares no `continue-on-error` gate step, so there is nothing to judge -- a verdict over zero ' + + 'gates is not a pass (#4690)'; + return result; + } + + result.failFast = status !== 'success'; + for (const step of roster) { + const entry = step.id === null ? undefined : context[step.id]; + let verdict; + let detail; + if (step.id === null) { + verdict = 'unread'; + detail = 'it sets continue-on-error but has no `id:`, so the steps context never carries its outcome'; + } else if (!entry || typeof entry !== 'object' || typeof entry.outcome !== 'string') { + verdict = 'unread'; + detail = `the steps context carries no outcome for id \`${step.id}\``; + } else if (entry.outcome === 'success') { + verdict = 'passed'; + } else if (entry.outcome === 'failure') { + verdict = 'red'; + detail = 'outcome `failure`'; + } else if (entry.outcome === 'cancelled') { + verdict = 'red'; + detail = 'outcome `cancelled`'; + } else if (entry.outcome === 'skipped' && step.cond !== null && !result.failFast) { + verdict = 'skippedByCondition'; + detail = `its own \`if:\` turned it off (${step.cond})`; + } else if (entry.outcome === 'skipped') { + verdict = 'didNotRun'; + detail = result.failFast + ? 'skipped behind a failed fail-fast step' + : 'skipped, and it carries no `if:` of its own that could have turned it off'; + } else { + verdict = 'unread'; + detail = `unrecognised outcome \`${entry.outcome}\``; + } + const row = { ...step, outcome: entry?.outcome ?? null, verdict, detail: detail ?? null }; + result.gates.push(row); + result[verdict].push(row); + } + result.ok = + !result.failFast && result.red.length === 0 && result.didNotRun.length === 0 && result.unread.length === 0; + return result; +} + +/** Escape a workflow-command message (the part after `::`). */ +export function escapeCommandData(text) { + return String(text).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +} + +/** Escape a workflow-command property value (`title=…`). */ +export function escapeCommandProperty(text) { + return escapeCommandData(text).replace(/:/g, '%3A').replace(/,/g, '%2C'); +} + +/** + * The log lines, the annotations among them. Every gate is listed, green ones + * included, so the log alone answers "what ran in this lane". + * + * @param {ReturnType} v + * @param {string} jobId + * @returns {string[]} + */ +export function renderVerdict(v, jobId) { + const out = []; + if (v.refusal) { + out.push(`::error title=${escapeCommandProperty(`Lane verdict: ${jobId}`)}::${escapeCommandData( + `NOT MEASURED -- ${v.refusal}. A verdict that cannot read its lane has not passed it.`, + )}`); + out.push(`${SCRIPT}: ${jobId}: PREREQUISITE NOT MET -- ${v.refusal}.`); + return out; + } + const tag = { passed: 'ok ', red: 'RED ', didNotRun: 'NOT RUN ', unread: 'UNREAD ', skippedByCondition: 'skipped ' }; + for (const g of v.gates) out.push(` ${tag[g.verdict]} ${g.name}${g.detail ? ` -- ${g.detail}` : ''}`); + out.push(''); + if (v.failFast) { + out.push( + `::error title=${escapeCommandProperty(`Lane verdict: ${jobId}`)}::${escapeCommandData( + `a step without continue-on-error failed (job status \`${v.jobStatus}\`), so this lane is red already; ` + + `${v.didNotRun.length} gate(s) behind it never ran and are NOT MEASURED, not passed.`, + )}`, + ); + } + for (const g of v.red) { + out.push(`::error title=${escapeCommandProperty(`Red gate: ${g.name}`)}::${escapeCommandData( + `gate step "${g.name}" (id ${g.id}) failed -- its own log above says why. continue-on-error let the gates ` + + 'behind it run; this verdict keeps the lane red.', + )}`); + } + for (const g of v.unread) { + out.push(`::error title=${escapeCommandProperty(`Unread gate: ${g.name}`)}::${escapeCommandData( + `gate step "${g.name}" cannot be read: ${g.detail}. An unread gate is not a pass.`, + )}`); + } + if (!v.failFast) { + for (const g of v.didNotRun) { + out.push(`::error title=${escapeCommandProperty(`Gate did not run: ${g.name}`)}::${escapeCommandData( + `gate step "${g.name}" did not run: ${g.detail}.`, + )}`); + } + } + const counts = + `${v.gates.length} gate(s): ${v.passed.length} passed, ${v.red.length} red, ` + + `${v.didNotRun.length} did not run, ${v.unread.length} unread, ` + + `${v.skippedByCondition.length} turned off by their own if:`; + if (v.ok) { + out.push(`${SCRIPT}: ${jobId}: OK -- ${counts}.`); + } else { + const named = v.red.length > 0 ? ` Red: ${v.red.map((g) => g.name).join('; ')}.` : ''; + out.push(`${SCRIPT}: ${jobId}: FAILED -- ${counts}.${named}`); + } + return out; +} + +/** The same verdict as markdown, for the job summary; written only when not green. */ +export function renderSummary(v, jobId) { + if (v.refusal) return `## Lane verdict: \`${jobId}\`\n\n**NOT MEASURED** -- ${v.refusal}.\n`; + const rows = v.gates + .filter((g) => g.verdict !== 'passed') + .map((g) => `| ${g.verdict === 'skippedByCondition' ? 'skipped (own `if:`)' : g.verdict} | ${g.name.replace(/\|/g, '\\|')} |`); + return [ + `## Lane verdict: \`${jobId}\``, + '', + v.failFast + ? `A fail-fast step failed (job status \`${v.jobStatus}\`); the gates behind it never ran.` + : `${v.red.length} red gate(s) out of ${v.gates.length}; every gate ran, so this is a count, not a lower bound.`, + '', + '| verdict | gate |', + '|:--|:--|', + ...rows, + '', + ].join('\n'); +} + +async function loadWorkflowSteps(workflowPath, jobId) { + const { parse } = await requireDependency('yaml', () => import('yaml'), import.meta.url); + let text; + try { + text = readFileSync(join(REPO_ROOT, workflowPath), 'utf8'); + } catch (err) { + throw new LaneVerdictRefusal(`the workflow file \`${workflowPath}\` cannot be read (${err.code ?? err.message})`); + } + let doc; + try { + doc = parse(text); + } catch (err) { + throw new LaneVerdictRefusal(`the workflow file \`${workflowPath}\` does not parse (${err.message.split('\n')[0]})`); + } + return laneSteps(doc, jobId); +} + +async function main() { + const jobId = String(process.env.GITHUB_JOB ?? '').trim(); + const workflowPath = workflowPathFromRef(process.env.GITHUB_WORKFLOW_REF); + let v; + try { + if (jobId === '') throw new LaneVerdictRefusal('GITHUB_JOB is not set, so this is not a step of a job'); + if (workflowPath === null) { + throw new LaneVerdictRefusal('GITHUB_WORKFLOW_REF does not name a workflow file (`owner/repo/@`)'); + } + const raw = process.env.OS_LANE_STEPS; + if (raw === undefined || raw.trim() === '') { + throw new LaneVerdictRefusal('OS_LANE_STEPS is not set -- pass `${{ toJSON(steps) }}` from the step'); + } + let context; + try { + context = JSON.parse(raw); + } catch (err) { + throw new LaneVerdictRefusal(`OS_LANE_STEPS is not JSON (${err.message})`); + } + const steps = await loadWorkflowSteps(workflowPath, jobId); + v = judgeLane({ steps, context, jobStatus: process.env.OS_LANE_JOB_STATUS }); + } catch (err) { + if (!(err instanceof LaneVerdictRefusal)) throw err; + v = { refusal: err.message }; + } + + for (const line of renderVerdict(v, jobId || '(no job)')) console.log(line); + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (!v.ok && summaryFile) { + try { + appendFileSync(summaryFile, `${renderSummary(v, jobId || '(no job)')}\n`); + } catch (err) { + console.log(`${SCRIPT}: the job summary could not be written (${err.code ?? err.message}); the log above stands.`); + } + } + if (v.refusal) process.exit(EXIT_PREREQUISITE_NOT_MET); + process.exit(v.ok ? 0 : EXIT_FINDINGS); +} + +// --------------------------------------------------------------------------- +// Self-test -- pins the red paths, the outcome-not-conclusion read first. +// --------------------------------------------------------------------------- + +// Set only after the verdict line prints; the dispatch refuses when it is unset +// (AGENTS.md, "Writing a --self-test": the handshake). +let selfTestReachedVerdict = false; + +// Battery name -> minimum case count. A literal, so a deleted battery names +// itself in the refusal instead of taking its own floor with it. +const SELF_TEST_BATTERIES = Object.freeze({ + 'green lanes': 2, + 'outcome, never conclusion': 3, + 'skipped: own condition vs did not run': 4, + 'fail-fast step failed': 2, + 'unread gates': 3, + 'refusals': 4, + 'roster from workflow text': 5, + 'workflow ref parsing': 3, + 'rendering': 5, +}); +const SELF_TEST_BATTERY_FLOOR = 9; + +async function selfTest() { + const { parse } = await requireDependency('yaml', () => import('yaml'), import.meta.url); + const seen = new Map(); + let current = null; + let failed = 0; + const battery = (name) => { + current = name; + }; + const t = (label, cond) => { + seen.set(current, (seen.get(current) ?? 0) + 1); + if (cond) { + console.log(` ok [${current}] ${label}`); + } else { + failed += 1; + console.log(` FAIL [${current}] ${label}`); + } + }; + + const WORKFLOW = [ + 'jobs:', + ' lane:', + ' steps:', + ' - name: Checkout repository', + ' uses: actions/checkout@v7', + ' - name: Install dependencies', + ' run: pnpm install --frozen-lockfile', + ' - name: Select the gate families this run pays for', + ' id: gate-families', + ' run: bash select.sh', + ' - name: Gate one', + ' id: gate-one', + ' continue-on-error: true', + ' run: node one.mjs', + ' - name: Gate two', + ' id: gate-two', + ' continue-on-error: true', + ' run: node two.mjs', + " - name: 'Scoped gate'", + ' id: scoped-gate', + " if: steps.gate-families.outputs.x != 'skip'", + ' continue-on-error: true', + ' run: node scoped.mjs', + ' - name: Lane verdict', + ' if: ${{ !cancelled() }}', + ' run: node scripts/ci/lane-verdict.mjs', + ].join('\n'); + const steps = laneSteps(parse(WORKFLOW), 'lane'); + const ctx = (one, two, scoped, extra = {}) => ({ + 'gate-families': { outcome: 'success', conclusion: 'success', outputs: {} }, + 'gate-one': { outcome: one, conclusion: one === 'failure' ? 'success' : one, outputs: {} }, + 'gate-two': { outcome: two, conclusion: two === 'failure' ? 'success' : two, outputs: {} }, + 'scoped-gate': { outcome: scoped, conclusion: scoped === 'failure' ? 'success' : scoped, outputs: {} }, + ...extra, + }); + + battery('green lanes'); + { + const v = judgeLane({ steps, context: ctx('success', 'success', 'success'), jobStatus: 'success' }); + t('every gate success -> ok', v.ok && v.passed.length === 3 && v.gates.length === 3); + const w = judgeLane({ steps, context: ctx('success', 'success', 'skipped'), jobStatus: 'success' }); + t('a scoped gate its own if: turned off -> still ok', w.ok && w.skippedByCondition.length === 1); + } + + battery('outcome, never conclusion'); + { + // The shape GitHub records for a failed step under continue-on-error: + // outcome failure, conclusion success. Reading `conclusion` would pass it. + const v = judgeLane({ steps, context: ctx('failure', 'success', 'success'), jobStatus: 'success' }); + t('outcome failure under conclusion success -> RED', !v.ok && v.red.length === 1 && v.red[0].id === 'gate-one'); + const both = judgeLane({ steps, context: ctx('failure', 'failure', 'success'), jobStatus: 'success' }); + t('two red gates -> both named, in workflow order', both.red.map((g) => g.name).join('|') === 'Gate one|Gate two'); + const cancelled = judgeLane({ steps, context: ctx('success', 'cancelled', 'success'), jobStatus: 'success' }); + t('outcome cancelled -> RED', !cancelled.ok && cancelled.red.length === 1); + } + + battery('skipped: own condition vs did not run'); + { + const unconditional = judgeLane({ steps, context: ctx('skipped', 'success', 'success'), jobStatus: 'success' }); + t('a gate with no if: that skipped -> did not run, RED', !unconditional.ok && unconditional.didNotRun.length === 1); + const behind = judgeLane({ steps, context: ctx('skipped', 'skipped', 'skipped'), jobStatus: 'failure' }); + t('every gate skipped behind a failed fail-fast step -> all did not run', behind.didNotRun.length === 3); + t('...and a scoped gate there is NOT credited to its own condition', behind.skippedByCondition.length === 0); + const scoped = judgeLane({ steps, context: ctx('success', 'success', 'skipped'), jobStatus: 'success' }); + t('a scoped gate skipped in a healthy job -> its own condition', scoped.skippedByCondition[0]?.id === 'scoped-gate'); + } + + battery('fail-fast step failed'); + { + const v = judgeLane({ steps, context: ctx('success', 'success', 'success'), jobStatus: 'failure' }); + t('every gate green but job status failure -> RED (fail-fast)', !v.ok && v.failFast); + const w = judgeLane({ steps, context: ctx('success', 'success', 'success'), jobStatus: 'cancelled' }); + t('job status cancelled -> RED', !w.ok && w.failFast); + } + + battery('unread gates'); + { + const noId = laneSteps( + parse('jobs:\n lane:\n steps:\n - name: Silent gate\n continue-on-error: true\n run: x\n'), + 'lane', + ); + const v = judgeLane({ steps: noId, context: {}, jobStatus: 'success' }); + t('a continue-on-error step with no id -> UNREAD, RED', !v.ok && v.unread.length === 1 && v.unread[0].name === 'Silent gate'); + const missing = judgeLane({ steps, context: { 'gate-one': { outcome: 'success' } }, jobStatus: 'success' }); + t('ids absent from the steps context -> UNREAD, RED', !missing.ok && missing.unread.length === 2); + const odd = judgeLane({ steps, context: ctx('success', 'neutral', 'success'), jobStatus: 'success' }); + t('an unrecognised outcome -> UNREAD, RED', !odd.ok && odd.unread.length === 1); + } + + battery('refusals'); + { + const none = laneSteps(parse('jobs:\n lane:\n steps:\n - name: A\n run: x\n'), 'lane'); + t('zero gates -> refusal, never a pass', judgeLane({ steps: none, context: {}, jobStatus: 'success' }).refusal !== null); + t('context not an object -> refusal', judgeLane({ steps, context: [], jobStatus: 'success' }).refusal !== null); + t('empty job status -> refusal', judgeLane({ steps, context: ctx('success', 'success', 'success'), jobStatus: '' }).refusal !== null); + let threw = null; + try { + laneSteps(parse(WORKFLOW), 'no-such-job'); + } catch (err) { + threw = err; + } + t('a job the workflow does not declare -> refusal', threw instanceof LaneVerdictRefusal); + } + + battery('roster from workflow text'); + { + t('only continue-on-error steps are gates', steps.filter((s) => s.gate).map((s) => s.id).join(',') === 'gate-one,gate-two,scoped-gate'); + t('a fail-fast step with an id is not a gate', steps.find((s) => s.id === 'gate-families')?.gate === false); + t('a quoted name is read unquoted', steps.some((s) => s.name === 'Scoped gate')); + t('the if: of a scoped gate is read', steps.find((s) => s.id === 'scoped-gate')?.cond === "steps.gate-families.outputs.x != 'skip'"); + t( + 'continue-on-error: false and an absent key are not gates; an expression is', + continuesOnError(false) === false && continuesOnError(undefined) === false && + continuesOnError('false') === false && continuesOnError(true) === true && continuesOnError('${{ inputs.soft }}') === true, + ); + } + + battery('workflow ref parsing'); + { + t('a pull-request ref', workflowPathFromRef('objectstack-ai/objectstack/.github/workflows/lint.yml@refs/pull/1/merge') === '.github/workflows/lint.yml'); + t('a branch ref', workflowPathFromRef('o/r/.github/workflows/ci.yml@refs/heads/main') === '.github/workflows/ci.yml'); + t('no ref -> null', workflowPathFromRef('') === null && workflowPathFromRef(undefined) === null); + } + + battery('rendering'); + { + const v = judgeLane({ steps, context: ctx('failure', 'success', 'success'), jobStatus: 'success' }); + const text = renderVerdict(v, 'lane').join('\n'); + t('a red gate is named in an ::error:: annotation', /::error title=Red gate%3A Gate one::/.test(text)); + t('the final line says FAILED and names the red gate', /lane-verdict: lane: FAILED .* Red: Gate one\./.test(text)); + const green = renderVerdict(judgeLane({ steps, context: ctx('success', 'success', 'success'), jobStatus: 'success' }), 'lane'); + t('a green lane prints OK and no annotation', /lane-verdict: lane: OK/.test(green.at(-1)) && !green.some((l) => l.startsWith('::'))); + t('a refusal renders as NOT MEASURED', /NOT MEASURED/.test(renderVerdict({ refusal: 'x' }, 'lane').join('\n'))); + t('annotation escaping', escapeCommandProperty('a:b,c%\n') === 'a%3Ab%2Cc%25%0A'); + } + + // ── The floor: every declared battery ran at least its pinned cases ───── + const declared = Object.keys(SELF_TEST_BATTERIES); + const floor = []; + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floor.push(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR}`); + } + for (const name of seen.keys()) { + if (!declared.includes(name)) floor.push(`battery "${name}" registered cases but is not declared`); + } + for (const name of declared) { + const count = seen.get(name) ?? 0; + if (count < SELF_TEST_BATTERIES[name]) { + floor.push(`battery "${name}" registered ${count} case(s), below its pinned ${SELF_TEST_BATTERIES[name]}`); + } + } + for (const line of floor) console.log(` FAIL [floor] ${line}`); + failed += floor.length; + + const cases = [...seen.values()].reduce((a, b) => a + b, 0); + if (failed > 0) { + console.log(`\n${SCRIPT} self-test: FAILED (${failed} of ${cases} case(s) and floor checks).`); + process.exit(EXIT_FINDINGS); + } + console.log(`\n${SCRIPT} self-test: OK (${cases} case(s) across ${declared.length} batteries).`); + selfTestReachedVerdict = true; +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + await selfTest(); + if (!selfTestReachedVerdict) { + console.error( + `\n${SCRIPT} self-test: selfTest() returned without reaching its verdict, so no success line was\n` + + 'printed. Exiting 0 here would report a self-test that never finished as one that passed.', + ); + process.exit(EXIT_FINDINGS); + } + } else { + await main(); + } +} From 1f39d3d80e2b4dc2bc418a36f92c0a21948130d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 08:21:05 +0000 Subject: [PATCH 2/6] ci(gates): every Lint and Type Check gate runs after a red; one verdict step per lane keeps the red; Test Core continues past a red package Every gate step of the eight Lint lanes and the four Type Check lanes now carries continue-on-error and an id; setup, install, the gate-family selector and the workspace builds stay fail-fast. Each lane ends with a Lane verdict step that reads every gate's outcome from the steps context, names the red ones and exits 1, so the lane and its required aggregate still conclude failure. It replaces the unmeasured-tail reporter pair, whose tail is now empty behind a gate red and exact (offline) behind a setup red. Test Core runs turbo with --continue=dependencies-successful and no longer skips the slice leg after a red whole-package leg; STATUS keeps the first non-zero exit. Claude-Session: https://claude.ai/code/session_012zwsLMxQBwHUYJgPyrpEvx Co-authored-by: Claude --- .github/workflows/ci.yml | 43 +- .github/workflows/lint.yml | 833 ++++++++++++++----- AGENTS.md | 4 +- scripts/report-unmeasured-gate-tail.mjs | 1000 ----------------------- 4 files changed, 667 insertions(+), 1213 deletions(-) delete mode 100644 scripts/report-unmeasured-gate-tail.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70300f9f967..c6f374e89b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -912,11 +912,35 @@ jobs: # happens even when a leg failed, because a red suite is exactly when # the completeness guard earns its keep. # - # ⛔ A failing leg STOPS the remaining ones, the same way turbo stops - # scheduling on the first failure inside one run. Carrying on would add - # a second full suite to a job that is already red and already inside a - # `timeout-minutes` wall — turning an informative red into a killed job with no - # attestation at all, which is the #16173 failure mode itself. + # ⭐ ONE PACKAGE'S RED STOPS NO OTHER PACKAGE (#23191; #23177 ruling B, + # item A1, comment 6139629628). Both turbo invocations below pass + # `--continue=dependencies-successful`: a failed `test` task no longer + # cancels the tasks still queued beside it, and only a task whose own + # dependency failed is withheld, because it would test a broken build. + # A failed leg no longer skips the legs after it either. Turbo still + # exits non-zero when any task failed, `STATUS` keeps the FIRST + # non-zero exit, and the step exits with it, so the shard is red + # exactly when it was before — and now names every red package of the + # shard in one run instead of the first one turbo reached. + # + # Measured before this was written: a three-package probe (turbo + # 2.11.5, `a#test` exits 1, `c` depends on `a`) ran `b` and `c` to + # completion and exited 1 with `Failed: a#test`; without the flag the + # same run stopped at `a` (`Tasks: 1 successful, 4 total`), also exit + # 1. The flag moves no task hash (`--dry=json` on this tree, plain vs + # `--continue=dependencies-successful`: 5 of 5 identical), so the + # main-seeded cache above still replays. + # + # What it costs is time on a red shard, and the wall above already + # holds it. A red leg now runs as long as a green one, and the legs + # together are the shard's own planned work (each slice is placed at + # its plain weight), so a red shard takes what a full one takes — the + # shape of the slowest job the wall above was sized on (27m05s, every + # package executed and the CLI as all 3 slices). A leg + # the stall guard kills still lands inside p + s + 20m, the budget + # the wall was sized to, whichever leg it is: the legs after it are + # the rest of `s`, not a second suite on top of it. Before this, a red + # stopped the run early, so the hidden reds cost another CI cycle each. # # `test test:repo` (#16466): six heavy packages split their suite into # two turbo tasks -- `test` (package-local inputs) and `test:repo` @@ -938,7 +962,7 @@ jobs: if [ "$LEG" = __whole__ ]; then [ -n "$FILTERS" ] || continue LOG="$RUNNER_TEMP/test-core-packages.log" - set -- pnpm turbo run test test:repo $FILTERS --concurrency=4 --summarize --log-order=stream + set -- pnpm turbo run test test:repo $FILTERS --concurrency=4 --continue=dependencies-successful --summarize --log-order=stream else PKG="${LEG%%=*}" SLICE="${LEG#*=}" @@ -1009,11 +1033,14 @@ jobs: # is empty); measure-test-shard-timings.mjs resolves that digest # against the k/n the partitioner can emit and refuses one it # cannot, so a slice is never timed as the whole package. - set -- env "OS_TEST_SHARD=$SLICE" pnpm turbo run test "--filter=$PKG" --concurrency=4 --summarize --log-order=stream + set -- env "OS_TEST_SHARD=$SLICE" pnpm turbo run test "--filter=$PKG" --concurrency=4 --continue=dependencies-successful --summarize --log-order=stream fi LOGS="$LOGS $LOG" node scripts/run-with-stall-guard.mjs --log "$LOG" --stall-minutes 10 \ - --report-dir "$RUNNER_TEMP/stall-reports" -- "$@" || { STATUS=$?; break; } + --report-dir "$RUNNER_TEMP/stall-reports" -- "$@" || { + CODE=$? + if [ "$STATUS" -eq 0 ]; then STATUS=$CODE; fi + } done # Only over logs that EXIST, and via an explicit `if` rather than a diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 141acec6422..66992f0b198 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -148,21 +148,44 @@ concurrency: # ───────────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────────── -# ⭐ GATE ORDER IS DELIBERATE, not topical (#13690, maintainer ruling -# 2026-09-03): the `Lint & Repo Gates` job below runs its gates sequentially -# and aborts at the first non-zero exit, so the cheap, frequently-red gates run -# FIRST — `Docs anchors resolve to real headings` and `ADR anchors + number -# uniqueness` sit immediately after the six setup steps because they produced -# 78% of this job's reds, and running them there shrinks the unmeasured tail on -# the majority of red runs at no cost to a green one (whose wall clock is the -# sum of its steps, and so is order-independent) — ⛔ so do not re-sort them -# back among their topical neighbours as tidying. +# ⭐ EVERY GATE RUNS, AND ONE VERDICT STEP KEEPS THE RED (#23191; #23177 +# ruling B, item A1, comment 6139629628). Every gate step of a `Lint · *` lane +# and of a `Type Check · *` lane carries `continue-on-error: true` and an +# `id:`, so a red gate no longer skips the gates behind it: one run reports +# every red in the lane, where it used to report the first and leave the rest +# for the next CI cycle. The steps the gates depend on — checkout, Node and +# pnpm setup, the caches, the install, the gate-family selector and the +# workspace builds — carry neither and stay fail-fast: a gate run over a +# missing install or a half-built tree would only add reds about the runner. # -# Since #22991 those gates run in eight parallel lanes behind the -# `Lint & Repo Gates` aggregate, and the rule holds PER LANE: each lane runs -# its gates sequentially and aborts at its first red, every step kept the -# relative order it had in the single job, and the two anchor gates still sit -# immediately after the six setup steps, at the top of `lint-repo-tooling`. +# `continue-on-error` alone trades a red for silence: the failed step's +# `conclusion` becomes `success` and so does the job's. What keeps the red is +# the LAST step of every lane, `Lane verdict` (`scripts/ci/lane-verdict.mjs`, +# whose header is the authority). It reads the lane's gate roster from this +# file — every step that sets `continue-on-error` — reads each gate's +# `outcome` from the `steps` context (the result BEFORE continue-on-error is +# applied; ⛔ never the `conclusion` after it), prints the red ones by name +# and exits 1. So a lane with a red gate still concludes `failure`, and the +# `Lint & Repo Gates` and `TypeScript Type Check` aggregates still go red. +# Adding a gate step: give it a unique `id:` and `continue-on-error: true`, +# like its neighbours. Without the `id:` the verdict cannot read it and reds +# the lane naming it; without the flag it is fail-fast again, and a red there +# hides every gate behind it once more — still red, one red per cycle. +# +# One gate feeds a later one: `check:authorable-surface` writes the gitignored +# json-schema tree that `check:docs` renders from (both in +# `typecheck-source-gates`). When the first cannot generate the tree, the +# second refuses on a missing or stale tree and says to generate it — a second +# red that names its cause, never a pass. +# +# The ORDER of the gates therefore no longer shortens a red run. The #13690 +# order (maintainer ruling 2026-09-03: the cheap, frequently-red +# `Docs anchors resolve to real headings` and `ADR anchors + number +# uniqueness` first, because they produced 78% of the single job's reds) is +# kept as it stands, at the top of `lint-repo-tooling`, because moving steps +# buys nothing — but ⛔ no gate is placed anywhere to shorten a red run any +# more: the verdict step is what keeps the red. A green run's wall clock was +# always the sum of its steps; a red run's now is too. # ───────────────────────────────────────────────────────────────────────────── # ⭐ WHERE A GATE LIVES, stated once, so that a step whose note is a one-line # `Rationale:` pointer to its script's header need not argue it again. No @@ -214,8 +237,9 @@ jobs: # runs its fast tier on `pull_request` and `merge_group` and both tiers on # `schedule` and `push` (argued in `scripts/pm/check-dispatch-gates.mjs`'s # header). What each lane repeats is the six setup steps, the gate-family - # selector where the lane carries a scoped step, and the unmeasured-tail - # reporter pair. Every lane keeps the single job's full-history checkout: + # selector where the lane carries a scoped step, and, since #23191, the + # `Lane verdict` step that ends it (it replaced the unmeasured-tail + # reporter pair). Every lane keeps the single job's full-history checkout: # which of the ~190 gates read history was not re-derived for this split, # and a gate that loses its history degrades to "not verified" rather than # going red. @@ -224,8 +248,9 @@ jobs: # were `steps.gate-families.outputs.*` (nine `if:` lines, one step `id:`), # and no step built, installed or generated a file a later step read, so no # lane can miss an artifact another lane would have written. Each lane - # still runs its gates sequentially and stops at its first red, so the GATE - # ORDER note above `jobs:` holds per lane. + # runs its gates sequentially and, since #23191, runs every one of them + # whatever the gates before it said: the EVERY GATE RUNS note above + # `jobs:` holds per lane. # # THE CUT, from the per-step timestamps of those 70 runs (a lane's wall # rebuilt per run: that run's own setup steps plus the lane's steps as they @@ -404,8 +429,7 @@ jobs: # The `lint` job above holds the contract, the cut and the roster: read it # before moving a step between lanes. This lane: ESLint and the engine # test-double contract gate, the two heaviest always-on gates, side by - # side. It also carries the unmeasured-tail reporter's full argument and - # the `actions: read` scope it needs, which every lane repeats. + # side. name: Lint · ESLint runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20, on the checkout step) + BODY BUDGET, the @@ -415,13 +439,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - # ⭐ Read-only, and it buys exactly one thing: the LAST step of this job - # asks the jobs API how many of the steps below it never ran, because the - # size of that tail is the one fact no step can work out locally (the - # runner alone knows which step index it stopped at). See - # `scripts/report-unmeasured-gate-tail.mjs`. Nothing else in this job - # reads it, and no step writes anything anywhere. - actions: read steps: - name: Checkout repository @@ -471,6 +488,8 @@ jobs: # namespace imports (the dormant rule was never run in CI). Syntactic # only, so no build step needed. - name: ESLint + id: eslint + continue-on-error: true run: pnpm lint # Engine test-double contract gate (#4550, from #4434). A test double @@ -488,70 +507,25 @@ jobs: # can be broken while every fake is fine, and a scan that quietly stops # matching would report OK while reading nothing (#4868's family). - name: Engine test-double contract gate + id: engine-test-double-contract-gate + continue-on-error: true run: pnpm check:engine-double-contract - # ── NOT GATES. The two steps below judge nothing about the tree. ────── - # - # ⭐ One red in this job is a LOWER BOUND on the number of problems in the - # tree, not a count — every gate behind the failing one never executed, - # and a gate that never executed produced no output, which is the same - # shape a passing gate has. The GATE ORDER note above `jobs:` already - # names this quantity ("the unmeasured tail") and is tuned to shrink it; - # until now nothing printed it. These steps print it. - # - # ⛔ The fix is NOT to make the skipped gates run: that would undo the - # adjudicated fail-fast order. ⛔ Nor is it `continue-on-error`, which - # trades a red for silence. The job's conclusion is untouched. - # - # Measured before this was written, on run 34468505490 of this very - # workflow (job 102842713265, failed at step #55): the jobs API and the web - # UI DO mark the un-run steps `skipped` — 112 of them there — but they - # never count them, and they use that same word for a step its own `if:` - # turned off, so the one surface that says it cannot tell the two apart. - # The check-run `output` was null on title, summary and text alike, and the - # job log — what an API or agent reader gets — said nothing at all. So what - # is added here is a machine-readable COUNT, on all three surfaces. - - # Offline, no token, no network: drives the reporter's judge() over - # recorded jobs-API shapes, and checks that the step below still hands - # itself its own name. Wired as a step because an unrun self-test is a - # phantom check. Sub-second. - # - # ⭐ `if: failure()` for the same reason the step below carries it, and it - # is the whole negative control: a green run of this job must be BYTE - # IDENTICAL to what it was before any of this existed. A self-test that - # ran unconditionally would print one line on every green run — one line - # is still new noise, and this change is not allowed to cost any. It is - # exercised on exactly the runs its subject is used on, which in this repo - # is not a rare event. ⛔ Do not "fix" this by dropping the condition. - # It fails LOUDLY: it is a separate step, so a broken reporter shows up as - # its own red step rather than as a missing paragraph. - - name: Unmeasured-gate-tail reporter self-test - if: failure() - run: node scripts/report-unmeasured-gate-tail.mjs --self-test - # Runs BECAUSE something failed. On a green head `failure()` is false, so - # it is skipped and contributes zero bytes to the log, the summary and the - # annotations — measured, not assumed. It always exits 0: the job is - # already failed when it runs, and a non-zero exit here would only add a - # second annotation pointing at the reporter instead of at the gate. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's own `name:` above. The reporter subtracts - # it so that it does not list ITSELF among the steps that never ran — - # measured in run 34584689309, where it did exactly that, because the - # jobs API had not yet marked it `in_progress`. Drift between the two - # lines costs one extra, visibly silly line in the report; it cannot - # go wrong quietly. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-repo-tooling: # Lane 2 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the # cut). The always-on gates that ran from the top of the single job down - # to the two tenancy censuses: the docs and ADR anchors (still first, per - # the GATE ORDER note above `jobs:`), the `scripts/**` guards and + # to the two tenancy censuses: the docs and ADR anchors (still first; the + # EVERY GATE RUNS note above `jobs:` says why the order no longer + # matters), the `scripts/**` guards and # shared-module self-tests, the PM tooling self-tests, the Claude hook # guards, the docs authoring gates and both censuses. name: Lint · repo & tooling gates @@ -563,9 +537,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - # The unmeasured-tail reporter at the end of this lane: `lint-eslint` - # argues the scope. - actions: read steps: - name: Checkout repository @@ -645,6 +616,8 @@ jobs: # Invoked as `node` rather than through a `pnpm check:*` alias: see the # GATE INVOCATION IDIOM note at the top of this file. - name: Docs anchors resolve to real headings + id: docs-anchors-resolve-to-real-headings + continue-on-error: true run: | pnpm check:doc-anchors node scripts/check-system-context-census.mjs --self-test @@ -664,6 +637,8 @@ jobs: # directories every run, because a collision is rare enough that nobody # would otherwise see the gate fail before it mattered. - name: ADR anchors + number uniqueness (governed code names its decision) + id: adr-anchors-number-uniqueness-governed-code + continue-on-error: true run: pnpm check:adr-anchors # @objectstack/verify stand-in erasure guard (#6399). The third and @@ -688,15 +663,21 @@ jobs: # a green run over nothing. `pnpm lint` alone cannot tell "clean tree" # from "matched nothing". Runs its own --self-test first. - name: "@objectstack/verify stand-in erasure guard" + id: objectstack-verify-stand-in-erasure-guard + continue-on-error: true run: pnpm check:verify-stand-in # Rationale: `scripts/check-nul-bytes.mjs` header (#3127, #4890, #5157, #5460, # #6984, #5579, #5681). - name: Raw control-byte guard + id: raw-control-byte-guard + continue-on-error: true run: pnpm check:nul-bytes # Rationale: `scripts/check-gitlink-declared.mjs` header (#17472, #17468). - name: Staged gitlinks are declared in .gitmodules + id: staged-gitlinks-are-declared-in-gitmodules + continue-on-error: true run: pnpm check:gitlink-declared # The first three of these are the shared modules the two `scripts/**` @@ -779,6 +760,8 @@ jobs: # prints its own verdict whatever the other three do, and it does so early # in the job. - name: scripts/ shared-module self-tests (parse · entry predicate · comment mask · prerequisite frame) + id: scripts-shared-module-self-tests-parse-entry + continue-on-error: true run: | # Tolerate-and-collect (#10814) — see the note above this step. Each # self-test runs unconditionally and prints its own verdict; the step @@ -835,6 +818,8 @@ jobs: # Scans ~121 scripts/ files plus a read-only census of the rest, no # spawns; ~0.6s. - name: scripts/ TypeScript parses go through one module + id: scripts-typescript-parses-go-through-one + continue-on-error: true run: pnpm check:parse-guard # Nobody writes a NEW private comment-stripper (#12307). @@ -869,6 +854,8 @@ jobs: # GATE INVOCATION IDIOM note at the top of this file. # Scans ~5k files under packages/** + examples/**, no spawns; ~2s. - name: No new private comment-strippers (all route through js-comment-mask) + id: no-new-private-comment-strippers-all-route + continue-on-error: true run: | node scripts/check-comment-mask-adoption.mjs --self-test node scripts/check-comment-mask-adoption.mjs @@ -886,6 +873,8 @@ jobs: # Hermetic: real fixture files in a temp dir, no network, no node_modules # beyond `typescript`; ~1s. - name: Declaration references resolve (#19402) — self-test + id: declaration-references-resolve-self-test + continue-on-error: true run: node scripts/check-dts-references.mjs --self-test # A package's published `src/**` may only import workspace packages its own @@ -905,6 +894,8 @@ jobs: # GATE INVOCATION IDIOM note at the top of this file. # Scans ~2k non-test src files across 78 workspace packages, no spawns; ~1s. - name: Published src imports only declared workspace deps + id: published-src-imports-only-declared + continue-on-error: true run: | node scripts/check-undeclared-dep-imports.mjs --self-test node scripts/check-undeclared-dep-imports.mjs @@ -912,12 +903,16 @@ jobs: # Rationale: `scripts/check-keyed-text-bounds.mjs` header (#12147, #11374, #12058, # #12143). - name: Keyed text-family columns declare their bound (#12147) + id: keyed-text-family-columns-declare + continue-on-error: true run: | node scripts/check-keyed-text-bounds.mjs --self-test node scripts/check-keyed-text-bounds.mjs # Rationale: `scripts/check-bash32-floor.mjs` header (#12221). - name: shell scripts hold the bash 3.2 floor + id: shell-scripts-hold-the-bash-3-2-floor + continue-on-error: true run: pnpm check:bash32-floor # Stack-collection enumerations vs the schema (#6242). `stack.zod.ts` @@ -930,6 +925,8 @@ jobs: # a time before, always after a missing key silently dropped a collection. # A deviation is legal here only as a waiver row carrying its reason. - name: Stack-collection enumerations answerable to stack.zod.ts + id: stack-collection-enumerations + continue-on-error: true run: pnpm check:stack-collection-maps # The vendored HotCRM permissions artifact has NO non-test loader (#13419). @@ -949,6 +946,8 @@ jobs: # Invoked as `node` rather than `pnpm check:*`: see the GATE INVOCATION # IDIOM note at the top of this file. ~1s, no spawns. - name: 'The #13419 name-fold fixture has no non-test loader' + id: the-name-fold-fixture-has-no-non-test-loader + continue-on-error: true run: | node scripts/check-position-name-fold-loaders.mjs --self-test node scripts/check-position-name-fold-loaders.mjs @@ -963,6 +962,8 @@ jobs: # who hits it. The sixth member (`description`) is a resolver output, kept # as a ledger row that is checked from both ends rather than skipped. - name: Widget `options` census derived from the spec, not pinned + id: widget-options-census-derived-from-the-spec + continue-on-error: true run: pnpm check:widget-option-census # The sdui-parser LOCKSTEP invariant (#12719 / #13150). Two copies of the @@ -984,6 +985,8 @@ jobs: # to the parser subtree would go dormant on exactly the PR that moves the # pin. Cost is one TypeScript parse of seven small files, ~0.4 s. - name: sdui-parser stays in lockstep with objectui's copy + id: sdui-parser-stays-in-lockstep-with-objectui + continue-on-error: true run: pnpm check:sdui-lockstep # The checked-in SDUI public-tier manifest (#12924, ruled 2026-08-29: wire @@ -1002,6 +1005,8 @@ jobs: # artefact would go dormant on exactly the PR that moves the pin. # Direct invocation per the GATE INVOCATION IDIOM at the top of this file. - name: SDUI manifest is present, intact and fresh at the objectui pin + id: sdui-manifest-is-present-intact-and-fresh + continue-on-error: true run: node scripts/check-sdui-manifest.mjs --self-test && node scripts/check-sdui-manifest.mjs # pm-dispatch SKILL.md line ratchet (#7341 / #5925 item 7): the PM skill @@ -1013,6 +1018,8 @@ jobs: # only with a maintainer ruling quoted in the raising PR — is stated in # the script's header, which is authoritative. - name: PM skill line ratchet + id: pm-skill-line-ratchet + continue-on-error: true run: pnpm check:pm-skill-ratchet # pm-skill issue-ID lint (maintainer ruling 2026-08-12): operative @@ -1021,6 +1028,8 @@ jobs: # citations; rulings keep date + verbatim quote. The script header is # authoritative, including the one self-expiring legacy waiver. - name: PM skill issue-ID lint + id: pm-skill-issue-id-lint + continue-on-error: true run: pnpm check:pm-skill-id-lint # PM label description cap. GitHub hard-caps label descriptions at 100 @@ -1034,6 +1043,8 @@ jobs: # guard would red a label GitHub accepts. Unconditional, like # the other self-tested gates above. - name: PM label description cap + id: pm-label-description-cap + continue-on-error: true run: pnpm check:pm-label-desc-cap # Every ROOT_DIR_WATCH_HINTS declaration stays READABLE BY A TEXT SCANNER @@ -1054,6 +1065,8 @@ jobs: # form, which is exactly how the two earlier per-file pins failed. # Repo-wide sweep of authored JS/TS, no spawns; ~0.5s. - name: ROOT_DIR_WATCH_HINTS declarations are literals + id: root-dir-watch-hints-declarations + continue-on-error: true run: pnpm check:watch-hint-literal # ADR-0087 D4's per-release correctness gate (#17080). The REAL run needs @@ -1066,6 +1079,8 @@ jobs: # from/to version pointing at another release, and the two absences that # are legitimate. Fixture-only, no spawns; well under a second. - name: Per-release spec-changes gate self-test + id: per-release-spec-changes-gate-self-test + continue-on-error: true run: pnpm check:release-spec-changes # Part-of/closing-keyword guard self-test (#8476). The guard itself is a @@ -1086,6 +1101,8 @@ jobs: # `env:`), so unwiring the gate reddens here rather than going quiet. # Imports one sibling module, reads two files; ~0.1s. - name: Part-of closing-keyword guard self-test + id: part-of-closing-keyword-guard-self-test + continue-on-error: true run: pnpm check:partof-closing-keyword # Commit card-trailer refusal self-test. The check itself is a PRE-PUSH @@ -1101,6 +1118,8 @@ jobs: # deferral check it carried before), so unwiring it reddens here rather # than going quiet. Pure functions plus two file reads; ~0.1s. - name: Commit card-trailer pre-push refusal self-test + id: commit-card-trailer-pre-push-refusal + continue-on-error: true run: pnpm check:commit-card-trailers # Publish-smoke tarball pin-set self-test. The assertion it pins lives on @@ -1111,6 +1130,8 @@ jobs: # release run, not on the PR that opened it. Pure functions, synthetic # fixtures, no pnpm/workspace/network; ~0.05s. - name: Publish-smoke pin-set self-test + id: publish-smoke-pin-set-self-test + continue-on-error: true run: pnpm check:publish-smoke-pin # Single-claim path guard self-test (#9402). Same split as the step above @@ -1134,10 +1155,14 @@ jobs: # unwiring the gate reddens here rather than going quiet. # Dependency-free, reads two files; ~0.1s. - name: Single-claim path guard self-test + id: single-claim-path-guard-self-test + continue-on-error: true run: pnpm check:single-claim-paths # Rationale: `scripts/check-closing-target-claim.mjs` header (#15845). - name: Closing-target claim guard self-test + id: closing-target-claim-guard-self-test + continue-on-error: true run: pnpm check:closing-target-claim # PM half-state sweeper self-test (#8528). `scripts/pm/check-half-states.mjs` @@ -1200,6 +1225,8 @@ jobs: # constant from being read as a hint, or giving this gate its own file # like its neighbour, is a separate card and not a defect of this step. - name: PM half-state sweeper self-test + id: pm-half-state-sweeper-self-test + continue-on-error: true run: pnpm check:pm-half-states # Contract-review record recogniser self-test (#18042 · #18701). The @@ -1215,11 +1242,15 @@ jobs: # step) deleted that file with its retired `--pair` sweep — ⛔ not a gate # added beside it. - name: Contract-review record recogniser self-test + id: contract-review-record-recogniser-self-test + continue-on-error: true run: pnpm check:pm-record-recognisers # Rationale: `scripts/pm/check-prior-rulings.mjs` header (#17009). # ⛔ Self-test only: the live read is report-only and never becomes a gate. - name: Prior-ruling reader self-test + id: prior-ruling-reader-self-test + continue-on-error: true run: pnpm check:pm-prior-rulings # Stamped-post helper self-test (#17314) — the WRITE half of the rule that @@ -1266,6 +1297,8 @@ jobs: # are claims about separate processes and are measured on separate # processes, by their timestamps. - name: Write-pace throttle self-test + id: write-pace-throttle-self-test + continue-on-error: true run: pnpm check:pm-write-pace # The fleet identity and the two doors — same family, same split. The @@ -1279,23 +1312,35 @@ jobs: # bot as committer, no token in any remote or config file, a credential # helper reached from the environment alone). - name: Fleet token minter self-test + id: fleet-token-minter-self-test + continue-on-error: true run: pnpm check:pm-fleet-token - name: Issue-create door self-test + id: issue-create-door-self-test + continue-on-error: true run: pnpm check:pm-issue-create - name: with-fleet wrapper self-test + id: with-fleet-wrapper-self-test + continue-on-error: true run: pnpm check:pm-with-fleet - name: Stamped-post helper self-test + id: stamped-post-helper-self-test + continue-on-error: true run: pnpm check:pm-post-stamped # Rationale: `scripts/pm/label-write.mjs` header (#18085). - name: Label-write helper self-test + id: label-write-helper-self-test + continue-on-error: true run: pnpm check:pm-label-write # Rationale: `scripts/pm/close-cards.mjs` header (#19469). - name: Card-closure helper self-test + id: card-closure-helper-self-test + continue-on-error: true run: pnpm check:pm-close-cards # Fleet-write relay self-tests — the same family and the same split as @@ -1315,6 +1360,8 @@ jobs: # a merge, a review, a ref, contents, a workflow, a release or an # organization endpoint, and none issues a whole-set PUT. - name: Fleet-write payload validator self-test + id: fleet-write-payload-validator-self-test + continue-on-error: true run: pnpm check:pm-fleet-write-validate # The executor's self-test drives a fake platform through the sender gate @@ -1325,6 +1372,8 @@ jobs: # one summary row per request, and a known token that comes back out of # NO summary line, log line or error. - name: Fleet-write executor self-test + id: fleet-write-executor-self-test + continue-on-error: true run: pnpm check:pm-fleet-write-execute # The seat-side dispatcher's self-test pins the transport selector that @@ -1334,6 +1383,8 @@ jobs: # request id and waited to its conclusion under an injected clock, and # both ceilings answered UNCONFIRMED and never retried. - name: Fleet-write dispatcher self-test + id: fleet-write-dispatcher-self-test + continue-on-error: true run: pnpm check:pm-fleet-write-dispatch # Issue-transfer door self-test (#21295) — the same family and the same @@ -1362,10 +1413,14 @@ jobs: # `[alias-self-test-not-run] pnpm check:pm-issue-transfer`. The # stale-`finding` sweep step below is held the same way. - name: Issue-transfer door self-test + id: issue-transfer-door-self-test + continue-on-error: true run: pnpm check:pm-issue-transfer # Rationale: `scripts/pm/board-snapshot.mjs` header (#17390). - name: Board-snapshot archiver self-test + id: board-snapshot-archiver-self-test + continue-on-error: true run: pnpm check:pm-board-snapshot # Stale-`finding` sweep self-test (#16904) — same family and the same @@ -1401,6 +1456,8 @@ jobs: # that this file still aliases that definition rather than re-growing a # copy of it. - name: Stale-`finding` sweep self-test + id: stale-finding-sweep-self-test + continue-on-error: true run: pnpm check:pm-stale-finding # Changeset-deadline census self-test (#16850) — same family and the same @@ -1423,6 +1480,8 @@ jobs: # control) leaves a production run just as green, which is the shape # `check:self-test-wired` requires this step for. - name: Changeset-deadline census self-test + id: changeset-deadline-census-self-test + continue-on-error: true run: pnpm check:pm-changeset-deadline-census # Governed-merges audit self-test (#9495) — same family, same split as @@ -1444,6 +1503,8 @@ jobs: # than restating it: the copy that does not exist cannot go stale. # ⛔ Do not helpfully re-add the list here. - name: Governed-merges audit self-test + id: governed-merges-audit-self-test + continue-on-error: true run: pnpm check:pm-governed-merges # Governed-surface PROSE pin (#9525) — the other half of the step above. @@ -1463,6 +1524,8 @@ jobs: # staleness it catches arrives via an edit to the REGISTER, in a PR that # need not touch either prose file at all. - name: Governed-surface prose pin + id: governed-surface-prose-pin + continue-on-error: true run: pnpm check:pm-governed-prose # Settings deny-roster pin (#18281) — the same shape as the two steps @@ -1478,6 +1541,8 @@ jobs: # to keep in step — whose failure mode is a silent skip of the very # reconciliation this step is. - name: 'Settings deny-roster pin (#18281)' + id: settings-deny-roster-pin + continue-on-error: true run: pnpm check:pm-settings-deny-roster # Expected-skips roster self-test (#18308) — same family and the same @@ -1488,10 +1553,14 @@ jobs: # is the only instrument this roster's matching rules have, which is # exactly the family `check:self-test-wired` requires this step for. - name: Expected-skips roster self-test + id: expected-skips-roster-self-test + continue-on-error: true run: pnpm check:pm-expected-skips # Rationale: `scripts/pm/release-rehearsal-clone.mjs` header (#9555). - name: Release-rehearsal clone preflight self-test + id: release-rehearsal-clone-preflight-self-test + continue-on-error: true run: node scripts/pm/release-rehearsal-clone.mjs --self-test # Shallow-history guard self-tests (#9902). Three seat-run tools ask @@ -1566,6 +1635,8 @@ jobs: # `check:required-contexts` step — so this is a choice on the merits, # not a constraint. - name: Shallow-history guard self-tests + id: shallow-history-guard-self-tests + continue-on-error: true run: | # Tolerate-and-collect (#10814) — see the note above this step. Each # self-test runs unconditionally and prints its own verdict; the step @@ -1599,12 +1670,16 @@ jobs: # Rationale: `scripts/check-step-collectors.mjs` header (#10814). - name: Step-collector gate (self-tests that mask each other) + id: step-collector-gate-self-tests-that-mask + continue-on-error: true run: | node scripts/check-step-collectors.mjs --self-test node scripts/check-step-collectors.mjs # Rationale: `scripts/check-self-test-wired.mjs` header (#11150). - name: Self-test wiring gate + id: self-test-wiring-gate + continue-on-error: true run: | node scripts/check-self-test-wired.mjs --self-test node scripts/check-self-test-wired.mjs @@ -1657,6 +1732,8 @@ jobs: # this workflow alone, and dispatch-gates.mjs derives gate families from # either spelling. - name: PM ci-failure self-test + id: pm-ci-failure-self-test + continue-on-error: true run: node scripts/pm/ci-failure.mjs --self-test # os-regen-merge self-test (#12893). Mirrors the `Verify-lock entry-point @@ -1692,6 +1769,8 @@ jobs: # this card: two literal steps do not yet justify the machinery, and a third # is when to revisit it. - name: os-regen-merge self-test + id: os-regen-merge-self-test + continue-on-error: true run: bash scripts/pm/os-regen-merge.sh --self-test # Claude hook guard self-tests (#11514, objectstack half of @@ -1757,6 +1836,8 @@ jobs: # built tree, a generator) must say so there; this comment is not the # place to enumerate it. - name: Claude hook guard self-tests (worktree-first · stash ban) + id: claude-hook-guard-self-tests-worktree-first + continue-on-error: true run: | # Tolerate-and-collect (#10814) — see the note above this step. Each # matrix runs unconditionally and prints its own verdict; the step @@ -1803,10 +1884,14 @@ jobs: # while the examples (which ARE linted) stay clean. This fails on any bare # metadata literal for the 16 factory domains in a doc code block. - name: Doc/skill authoring guard + id: doc-skill-authoring-guard + continue-on-error: true run: pnpm check:doc-authoring # Rationale: `scripts/check-doc-frontmatter.mjs` header (#10493, #4690, #7484). - name: Docs frontmatter parses + id: docs-frontmatter-parses + continue-on-error: true run: | node scripts/check-doc-frontmatter.mjs --self-test node scripts/check-doc-frontmatter.mjs @@ -1838,6 +1923,8 @@ jobs: # INVOCATION IDIOM note at the top of this file. Filesystem read plus one # type-stripped import; sub-second. - name: Docs nav label stays out of the title surfaces + id: docs-nav-label-stays-out-of-the-title + continue-on-error: true run: | node scripts/check-docs-nav-label.mjs --self-test node scripts/check-docs-nav-label.mjs @@ -1890,6 +1977,8 @@ jobs: # corpus scale, it moves on any file added under those roots, and a comment # nothing checks is exactly where a stale number hides. - name: Tenant-audit census matches the tree + id: tenant-audit-census-matches-the-tree + continue-on-error: true run: | node scripts/check-tenant-audit-census.mjs --self-test node scripts/check-tenant-audit-census.mjs @@ -1897,25 +1986,20 @@ jobs: # Rationale: `scripts/check-platform-object-tenancy-census.mjs` header (#14957, # #15155). - name: Platform-object tenancy census matches the tree + id: platform-object-tenancy-census-matches + continue-on-error: true run: | node scripts/check-platform-object-tenancy-census.mjs --self-test node scripts/check-platform-object-tenancy-census.mjs - # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The - # reporter finds its own job by runner name, so each lane counts its own - # tail. Argued in full at the end of `lint-eslint`. ────────────────────── - - name: Unmeasured-gate-tail reporter self-test - if: failure() - run: node scripts/report-unmeasured-gate-tail.mjs --self-test - - # Runs only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-docs-code: # Lane 3 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -1932,9 +2016,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - # The unmeasured-tail reporter at the end of this lane: `lint-eslint` - # argues the scope. - actions: read steps: - name: Checkout repository @@ -1973,10 +2054,14 @@ jobs: # Rationale: `scripts/check-docs-single-h1.mjs` header (#12236, #12249, #12250, # #4690). - name: One `

` per docs page + id: one-h1-per-docs-page + continue-on-error: true run: pnpm check:docs-single-h1 # Rationale: `scripts/check-docs-redirects.mjs` header (#9014, #8948). - name: Docs redirect destinations resolve, and no chains + id: docs-redirect-destinations-resolve + continue-on-error: true run: pnpm check:docs-redirects # The docs site's `app/[lang]/` is a catch-all that matches ANY single @@ -2009,6 +2094,8 @@ jobs: # including the OG marker stripped of its dot -- and where the proxy # condition is observed flipping the catch-all requirement off. - name: Docs locale catch-all and OG card URL dot invariant + id: docs-locale-catch-all-and-og-card-url-dot + continue-on-error: true run: pnpm check:docs-locale-catch-all # #11050 route spellings taught in prose: every /api/v1 wire-path @@ -2038,6 +2125,8 @@ jobs: # frontmatter step above: see the GATE INVOCATION IDIOM note at the top of # this file. - name: Route spellings taught in prose match the ledgers (advisory) + id: route-spellings-taught-in-prose-match + continue-on-error: true run: | node scripts/check-doc-route-spelling.mjs --self-test node scripts/check-doc-route-spelling.mjs --advisory @@ -2045,6 +2134,8 @@ jobs: # Rationale: `scripts/check-react-page-adapter-contract.mjs` header (#10751, # #10288). - name: React pages honour the useAdapter() query and result contracts + id: react-pages-honour-the-useadapter-query + continue-on-error: true run: pnpm check:react-page-adapter-contract # #11576 page discovery vs ground truth: what makes something a SERVED @@ -2056,17 +2147,23 @@ jobs: # array must be declared `export const X: Page =` or via `definePage()`, # so a page can never again be served-but-uncovered. - name: Kernel-reaching pages are declared in a discoverable shape + id: kernel-reaching-pages-are-declared + continue-on-error: true run: pnpm check:page-declaration-shape # Rationale: `scripts/check-docs-section-name.mjs` header (#10830, #10709, #10579, # #10827). - name: Docs form-section examples carry a `name` + id: docs-form-section-examples-carry-a-name + continue-on-error: true run: | node scripts/check-docs-section-name.mjs --self-test node scripts/check-docs-section-name.mjs # Rationale: `scripts/check-published-readme-links.mjs` header (#9632). - name: Published-README links are followable off the docs site + id: published-readme-links-are-followable + continue-on-error: true run: pnpm check:published-readme-links # #9018 docs image tags: the same "example image tag N majors behind @@ -2104,6 +2201,8 @@ jobs: # that matches nothing. The self-test is where every limb is observed # failing, and where the X.Y.Z tag-table metavariable is observed excluded. - name: Docs image tags track packages/cli's version + id: docs-image-tags-track-packages-cli-version + continue-on-error: true run: pnpm check:docs-image-tag # #9064 — the self-test of the VERSION-TIME REWRITER that keeps the gate @@ -2127,6 +2226,8 @@ jobs: # so the change that breaks one breaks the other, and both reds should land # in the same place on the same PR. - name: Docs image-tag version-time rewriter self-test + id: docs-image-tag-version-time-rewriter + continue-on-error: true run: pnpm check:docs-image-tag-sync # #9348 — the same treatment for the OTHER version-time rewriter, argued @@ -2184,6 +2285,8 @@ jobs: # packages/create-objectstack/src/template-version-stamps.test.ts; two # harnesses asserting the same thing would be worse than one. - name: Template version-time rewriter self-test + id: template-version-time-rewriter-self-test + continue-on-error: true run: pnpm check:template-version-sync # #16485 — the BUILD-time generator beside the version-time one above, and @@ -2203,12 +2306,16 @@ jobs: # unparseable template, an empty templates directory), --check covers the # live tree. - name: Scaffold emission policy generated into the on-ramp + id: scaffold-emission-policy-generated + continue-on-error: true run: pnpm check:scaffold-emission-policy # Rationale: `scripts/sync-release-index-currency.mjs` header (#15332, #10232, # #11649). # ⛔ Self-test only; never `check-release-section-coverage`'s `--strict` arm. - name: Release-index currency version-time rewriter self-test + id: release-index-currency-version-time-rewriter + continue-on-error: true run: pnpm check:release-index-currency-sync # #4851: the docs-accuracy-audit workflow's default scope is a generated list @@ -2225,6 +2332,8 @@ jobs: # list is a docs rename, so a `packages/**` paths filter would blind the gate # to exactly its own failure mode. - name: Docs-audit scope is derived, not hand-kept + id: docs-audit-scope-is-derived-not-hand-kept + continue-on-error: true run: pnpm check:docs-audit-scope # ADR-0090 D3 vocabulary ratchet: "role" is reserved-forbidden in docs @@ -2232,17 +2341,23 @@ jobs: # boundary, ARIA samples, educational mentions); NEW occurrences fail. # Improvements ratchet the baseline down via --update. - name: Reserved-word ("role") docs ratchet + id: reserved-word-role-docs-ratchet + continue-on-error: true run: pnpm check:role-word # Rationale: `scripts/check-corpus-claim-drift.mjs` header (#13582, #13539, # #13745, #13532). - name: Teaching-corpus lexical anti-drift ratchet + id: teaching-corpus-lexical-anti-drift-ratchet + continue-on-error: true run: pnpm check:corpus-claim-drift # Rationale: `scripts/check-skill-identifier-liveness.mjs` header (#13678). # It lives in this file, unfiltered: its inputs are `skills/**` and `packages/**`, # and a paths filter on either would let the other drift unseen. - name: Published-skills identifier liveness gate (two legs) + id: published-skills-identifier-liveness-gate + continue-on-error: true run: pnpm check:skill-identifier-liveness # #11671: `os i18n extract --fill=default` fills GAPS only, so revising a @@ -2256,6 +2371,8 @@ jobs: # Existing drift is frozen in scripts/i18n-stale-fill-baseline.json with a # reason per entry; NEW drift fails. Improvements ratchet down via --update. - name: Stale translation fills (i18n) ratchet + id: stale-translation-fills-i18n-ratchet + continue-on-error: true run: pnpm check:i18n-stale-fill # #6319: content/docs/getting-started/quick-reference.mdx is the protocol @@ -2271,6 +2388,8 @@ jobs: # the count is a docs edit, so a packages/** paths filter would blind it to # its own failure mode. - name: Quick-reference section counts match their tables + id: quick-reference-section-counts-match + continue-on-error: true run: pnpm check:quick-reference-counts # #17388: three hand-written pages enumerate @objectstack/spec's importable @@ -2291,6 +2410,8 @@ jobs: # is a docs edit, so a packages/** paths filter would blind it to its own # failure mode. - name: Hand-written spec enumerations match the exports map + id: hand-written-spec-enumerations-match + continue-on-error: true run: pnpm check:docs-spec-enumerations # #11752: the "Overlay whitelist (shared-DB tenancy invariant)" table in @@ -2308,11 +2429,15 @@ jobs: # a docs edit, so a packages/** paths filter would blind it to its own # failure mode. - name: Overlay whitelist table matches the metadata type registry + id: overlay-whitelist-table-matches-the-metadata + continue-on-error: true run: pnpm check:overlay-whitelist-table # Rationale: `scripts/check-runtime-services-index.mjs` header (#9604, #9588, # #9630, #9684, #9629). - name: Runtime-services indexes enumerate the chapter's real pages + id: runtime-services-indexes-enumerate + continue-on-error: true run: pnpm check:runtime-services-index # #10738 generalises the gate above to every section landing page. The same @@ -2360,6 +2485,8 @@ jobs: # blind it to its own failure mode. Reads ~34 meta.json + index.mdx pairs, # no spawns, no build; ~0.1s. - name: Section landing indexes enumerate their meta.json pages + id: section-landing-indexes-enumerate-their-meta + continue-on-error: true run: | node scripts/check-section-landing-index.mjs --self-test node scripts/check-section-landing-index.mjs @@ -2408,6 +2535,8 @@ jobs: # Both existing collectors in this file carry self-tests ONLY, for the # same reason. Keep the sweep out of the collector block. - name: Symbol-anchor gate self-tests + id: symbol-anchor-gate-self-tests + continue-on-error: true run: | failed="" run_self_test() { @@ -2432,6 +2561,8 @@ jobs: fi - name: ADR symbol anchors resolve (no line numbers survive) + id: adr-symbol-anchors-resolve-no-line-numbers + continue-on-error: true run: node scripts/check-adr-symbol-anchors.mjs # The SECOND corpus (#15765), and it is a `defineCorpus` registration over @@ -2440,6 +2571,8 @@ jobs: # a production sweep inside the collector block re-invokes the stub the # harness plants and the count comes out 4/3. - name: scripts/** symbol anchors resolve (no line citations on tracked targets survive) + id: scripts-symbol-anchors-resolve-no-line + continue-on-error: true run: node scripts/check-scripts-symbol-anchors.mjs # The THIRD corpus (#17065) — `packages/spec/src/**` doc blocks, again a @@ -2454,6 +2587,8 @@ jobs: # Repairing those 7 edits `packages/spec/**` text, which is `domain:spec` # work with its own cards — the registration does not do it. - name: packages/spec/src/** doc-block symbol anchors resolve (no NEW line citations on tracked targets) + id: packages-spec-src-doc-block-symbol-anchors + continue-on-error: true run: node scripts/check-spec-docblock-symbol-anchors.mjs # NOTE: the standing platform test checklist (docs/qa/platform-checklist/) @@ -2486,6 +2621,8 @@ jobs: # INVOCATION IDIOM note at the top of this file. The `--self-test` leg runs # first because a checker whose own cases failed has no verdict worth printing. - name: Platform-checklist watchdog workflow pin + id: platform-checklist-watchdog-workflow-pin + continue-on-error: true run: | node scripts/check-platform-checklist-watchdog.mjs --self-test node scripts/check-platform-checklist-watchdog.mjs @@ -2498,6 +2635,8 @@ jobs: # occurrences today); tests, comments, skills/ and docs/ are excluded, and # driver-layer `execCtx.tenantId` is never matched. - name: Org-identifier authoring guard + id: org-identifier-authoring-guard + continue-on-error: true run: pnpm check:org-identifier # Authorization resolution must stay single-sourced (resolveAuthzContext, @@ -2505,6 +2644,8 @@ jobs: # security path (the REST-vs-dispatcher sys_user_role drift) and against an # entry point silently dropping the delegation. - name: Single authz resolver guard + id: single-authz-resolver-guard + continue-on-error: true run: pnpm check:authz-resolver # #11352. Product code may not read a test-RUNNER variable (`TEST`, @@ -2519,6 +2660,8 @@ jobs: # Dependency-free source scan over `**/src/**`, comments and literals # masked; runs its own --self-test first. - name: Runner-env posture guard + id: runner-env-posture-guard + continue-on-error: true run: pnpm check:runner-env-posture # #11341, the harness half of the same class the guard above closes in @@ -2552,6 +2695,8 @@ jobs: # because "exits non-zero" is the claim and no process can observe its own # exit status. Static AST over 82 sources; under a second, no build. - name: CLI test child-env guard + id: cli-test-child-env-guard + continue-on-error: true run: pnpm check:cli-test-child-env # #13940. A version stamp about a PINNED VENDOR is an attestation: it says a @@ -2572,6 +2717,8 @@ jobs: # its own population, so a scan that stopped matching would print a # confident green over a tree it never read. - name: Vendor version stamps + id: vendor-version-stamps + continue-on-error: true run: pnpm check:vendor-version-stamps # #12016. A CLI command id spelled as a string literal OUTSIDE the CLI @@ -2584,6 +2731,8 @@ jobs: # measured before the gate was written; the sibling sweep INSIDE # packages/cli is #11465 / #12177, and the two populations do not overlap. - name: CLI command-id literals resolve + id: cli-command-id-literals-resolve + continue-on-error: true run: pnpm check:cli-command-ids # #15393. `os package publish` ships its example set TWICE — the oclif @@ -2601,6 +2750,8 @@ jobs: # whitespace and ORDER — the two voices may differ, the invocations may not. # Reads two source files; no build, no install. - name: CLI examples match the documented block + id: cli-examples-match-the-documented-block + continue-on-error: true run: pnpm check:cli-examples-parity # #4093 follow-up. Discovery tells a consumer an absent capability is @@ -2611,6 +2762,8 @@ jobs: # CORE_SERVICE_PROVIDER is a real workspace package, and that no core # slot is missing an entry. - name: Service-provider remedy guard + id: service-provider-remedy-guard + continue-on-error: true run: pnpm check:service-providers # #3843 response-envelope guard. The route ledgers audit which routes EXIST; @@ -2626,6 +2779,8 @@ jobs: # absent from #3843's hand survey. An undeclared route module is an error, # never a default. Runs its own --self-test first. - name: Response-envelope guard + id: response-envelope-guard + continue-on-error: true run: pnpm check:route-envelope # Error-code casing guard (ADR-0112, #4003). The ledger's admission test @@ -2635,6 +2790,8 @@ jobs: # not parse its own response. That is how 208 of them accumulated across # 10 packages before batch 2. Runs its own --self-test first. - name: Error-code casing guard + id: error-code-casing-guard + continue-on-error: true run: pnpm check:error-code-casing # #8967: the docs publish an HTTP status per error code and the doors decide @@ -2650,6 +2807,8 @@ jobs: # `paths:` filter — either side of this reconciliation can move it, so filtering # on `packages/**` or on `content/**` would blind it to half its own subject. - name: Documented HTTP status matches the status the runtime emits + id: documented-http-status-matches-the-status + continue-on-error: true run: pnpm check:error-status-conformance # #8087: ADR-0112 closes `error.code`, and the dispatcher door did not @@ -2669,6 +2828,8 @@ jobs: # any package, and the registered vocabulary lives in packages/spec. # Runs its own --self-test first. - name: Dispatcher error-code vocabulary guard + id: dispatcher-error-code-vocabulary-guard + continue-on-error: true run: pnpm check:dispatcher-error-vocabulary # #13353: the PROVENANCE half of the same ledger. The gate above reports @@ -2686,10 +2847,14 @@ jobs: # green on exactly the PR that adds an unlisted stamper elsewhere. # Runs its own --self-test first (wired into the package script). - name: Error-code provenance guard + id: error-code-provenance-guard + continue-on-error: true run: pnpm --filter @objectstack/spec check:error-code-provenance # Rationale: `scripts/check-auth-mount-ledger.mjs` header (#10534, #9941, #10050). - name: Auth mount-vs-ledger guard + id: auth-mount-vs-ledger-guard + continue-on-error: true run: pnpm check:auth-mount-ledger # Vendor export contract (#16186). `@better-auth/core@1.7.3` deleted @@ -2708,6 +2873,8 @@ jobs: # a lockfile refresh and a new vendor import can each move it. Runs its # own --self-test first. - name: Vendor export contract + id: vendor-export-contract + continue-on-error: true run: pnpm check:vendor-export-contract # Optional-`error` sink contract (#9754, wired by #10608). A sink TYPE that @@ -2733,6 +2900,8 @@ jobs: # No `paths:` filter, for the standard reason: a filter on `packages/**` # would go dormant on the PR that edits the baseline. - name: Optional-`error` sink contract + id: optional-error-sink-contract + continue-on-error: true run: pnpm check:optional-error-sink # Namespace-wildcard fall-through guard (#4116). A handler mounted on @@ -2746,6 +2915,8 @@ jobs: # — and an undeclared mount is an error, never a default. Runs its own # --self-test first. - name: Wildcard fall-through guard + id: wildcard-fall-through-guard + continue-on-error: true run: pnpm check:wildcard-fallthrough # Raw `:type` route-param comparison guard (#6241). The `/meta/:type` @@ -2760,6 +2931,8 @@ jobs: # a textual scan would flag its own post-mortems. Zero exemptions today. # Runs its own --self-test first. - name: Normalized metadata-type guard + id: normalized-metadata-type-guard + continue-on-error: true run: pnpm check:meta-type-normalized # Filter-slot wire-alias parity guard (#8002). The ONE filter slot is @@ -2776,6 +2949,8 @@ jobs: # declaration is RED rather than an empty corpus reported as a pass # (#4690). Runs its own --self-test first. - name: Filter-slot wire-alias parity guard + id: filter-slot-wire-alias-parity-guard + continue-on-error: true run: pnpm check:filter-alias-parity # Init-service declaration guard (#4471, ADR-0116). The kernel's ordering @@ -2791,6 +2966,8 @@ jobs: # Declared tolerance stays in the plugin (optionalDependencies), never in a # checker-side ledger. Runs its own --self-test first. - name: Init-service declaration guard + id: init-service-declaration-guard + continue-on-error: true run: pnpm check:init-service-contract # Settings bind-window guard (#11045). The same argument as the gate above, @@ -2810,6 +2987,8 @@ jobs: # own --self-test first; that self-test is written so a depth-0-only walker # fails it, which is the failure mode #10250's census actually had. - name: Settings bind-window guard + id: settings-bind-window-guard + continue-on-error: true run: pnpm check:settings-bind-window # Durability-degradation log-level guard (#4632). #4460 raised ONE @@ -2826,11 +3005,15 @@ jobs: # a new durability seam, only stop known ones from regressing. Runs its own # --self-test first. - name: Durability-degradation log-level guard + id: durability-degradation-log-level-guard + continue-on-error: true run: pnpm check:durability-log-level # Rationale: `scripts/check-logger-receiver-detach.mjs` header (#12820, #12773, # #12792). - name: Logger receiver-detach guard + id: logger-receiver-detach-guard + continue-on-error: true run: pnpm check:logger-receiver-detach # Startup registry-verdict guard (#4777). One showcase cold start produced @@ -2849,6 +3032,8 @@ jobs: # cannot see #4769 at all (that "registry" is a database table). Runs its # own --self-test first. - name: Startup registry-verdict guard + id: startup-registry-verdict-guard + continue-on-error: true run: pnpm check:startup-registry-verdict # objectui pin-changeset guard (#4731). A frontend-only refresh reaches the @@ -2866,10 +3051,14 @@ jobs: # section — onto that same `classifyRange()` criterion, so both self-tests # run here: one criterion, two consumers, no room to drift apart. - name: objectui pin-changeset digest guard + id: objectui-pin-changeset-digest-guard + continue-on-error: true run: pnpm check:objectui-changeset # Rationale: `scripts/bump-objectui.selftest.sh` header (#10797). - name: objectui pin write-ordering guard + id: objectui-pin-write-ordering-guard + continue-on-error: true run: pnpm check:objectui-bump # Changeset-family gate self-tests (#6509). The SELF-TEST halves only — @@ -2912,23 +3101,18 @@ jobs: # repos in $TMPDIR and read two files (this workflow and # pr-automation.yml). ~0.8s + ~5.0s. - name: Changeset-family gate self-tests + id: changeset-family-gate-self-tests + continue-on-error: true run: pnpm check:changeset-gate-self-tests - # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The - # reporter finds its own job by runner name, so each lane counts its own - # tail. Argued in full at the end of `lint-eslint`. ────────────────────── - - name: Unmeasured-gate-tail reporter self-test - if: failure() - run: node scripts/report-unmeasured-gate-tail.mjs --self-test - - # Runs only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-ci-engine: # Lane 4 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -2947,9 +3131,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - # The unmeasured-tail reporter at the end of this lane: `lint-eslint` - # argues the scope. - actions: read # ⭐ Added by #18224, for the issue-citation step far below: it resolves # the numbers THIS change cites against the board. Read-only and one # scope wide — no issue, comment, label or assignee is ever written. It @@ -3018,6 +3199,8 @@ jobs: # self-tests (`lint-docs-code`) — # this runs its real check every time, unconditionally. - name: Lockstep package-count guard + id: lockstep-package-count-guard + continue-on-error: true run: pnpm check:lockstep-package-count # Route-ledger census guard (#16758). `packages/runtime/src/route-ledger.ts` @@ -3033,6 +3216,8 @@ jobs: # reds and a deliberate change shows its digits moving in the same diff. # Full-repo state, not diff-shaped, so it runs its real check unconditionally. - name: Route-ledger census guard + id: route-ledger-census-guard + continue-on-error: true run: pnpm check:route-ledger-census # Release-notes drift guard: the platform is one version-locked train, so @@ -3040,6 +3225,8 @@ jobs: # release page at content/docs/releases/v.mdx. Catches the gap that # let v10–v14 ship with no page while spec was already at 14.x. - name: Release-notes drift guard + id: release-notes-drift-guard + continue-on-error: true run: pnpm check:release-notes # Release-page STATUS guard (#8892). The guard above proves a released @@ -3052,6 +3239,8 @@ jobs: # describe a shipped release. Scoped to v16+ by maintainer ruling; the # gate prints that cutoff and its reason on both the OK and failure paths. - name: Release-page status guard + id: release-page-status-guard + continue-on-error: true run: pnpm check:release-page-status # Release SECTION-COVERAGE guard (#10268). The guard above is keyed on the @@ -3078,6 +3267,8 @@ jobs: # No `pnpm check:*` alias: see the GATE INVOCATION IDIOM note at the top # of this file. - name: Release section-coverage guard + id: release-section-coverage-guard + continue-on-error: true run: | node scripts/check-release-section-coverage.mjs --self-test node scripts/check-release-section-coverage.mjs @@ -3097,10 +3288,14 @@ jobs: # rest — @objectstack/spec keeps its release, which is where ADR-0087 D4's # spec-changes.json is attached. - name: Release-body limit guard + id: release-body-limit-guard + continue-on-error: true run: pnpm check:release-body # Rationale: `scripts/release-verify-npm.mjs` header (#15321). - name: Post-publish npm verification self-test + id: post-publish-npm-verification-self-test + continue-on-error: true run: node scripts/release-verify-npm.mjs --self-test # Release version-commit selection (ADR-0125 D1 as amended 2026-09-29). @@ -3117,6 +3312,8 @@ jobs: # Invoked as `node` rather than through a `pnpm check:*` alias: see the # GATE INVOCATION IDIOM note at the top of this file. - name: Release version-commit selection self-test + id: release-version-commit-selection-self-test + continue-on-error: true run: node scripts/release-pending-publish.mjs --self-test # #3825 Node-version drift guard: a runtime pin is 18 separate string @@ -3130,6 +3327,8 @@ jobs: # cases silently skipped (#3812). .nvmrc is now the single source of # truth, and this holds every workflow to it. - name: Node-version drift guard + id: node-version-drift-guard + continue-on-error: true run: pnpm check:node-version # pnpm-acquisition census. Sibling of the guard above, and here for the @@ -3154,11 +3353,15 @@ jobs: # for the pnpm store cache shells out to pnpm, so pnpm must be acquired # strictly earlier. Runs its own --self-test first. - name: pnpm-acquisition census + id: pnpm-acquisition-census + continue-on-error: true run: pnpm check:pnpm-acquisition # Rationale: `scripts/check-workflow-status-functions.mjs` header (#5343, #4900, # #4928, #4690). - name: Workflow status-function guard + id: workflow-status-function-guard + continue-on-error: true run: pnpm check:workflow-status-functions # Additive-label-write self-test (#10703). `pr-automation.yml` writes this @@ -3179,10 +3382,14 @@ jobs: # republishes it as `skipped`, which washes green -- see # check-required-contexts.mjs), so a red there blocks nothing. - name: Additive label-write self-test + id: additive-label-write-self-test + continue-on-error: true run: node scripts/pr-labels.mjs --self-test # Rationale: `scripts/check-whole-set-label-write.mjs` header (#10778, #10703). - name: No whole-set label PUT anywhere in the repo (#10778) + its self-test + id: no-whole-set-label-put-anywhere-in-the-repo + continue-on-error: true run: | node scripts/check-whole-set-label-write.mjs --self-test node scripts/check-whole-set-label-write.mjs @@ -3190,12 +3397,16 @@ jobs: # Rationale: `scripts/check-cross-repo-closer-outcome.mjs` header (#9595, #9575, # #9917). - name: Cross-repo closer outcome contract + id: cross-repo-closer-outcome-contract + continue-on-error: true run: | node scripts/check-cross-repo-closer-outcome.mjs --self-test node scripts/check-cross-repo-closer-outcome.mjs # Rationale: `scripts/check-closing-keyword-parity.mjs` header (#9755). - name: Closing-keyword parser parity + id: closing-keyword-parser-parity + continue-on-error: true run: | node scripts/check-closing-keyword-parity.mjs --self-test node scripts/check-closing-keyword-parity.mjs @@ -3240,12 +3451,16 @@ jobs: # reason as the step above: see the GATE INVOCATION IDIOM note at the top # of this file. - name: Merge-queue triage outcome contract + id: merge-queue-triage-outcome-contract + continue-on-error: true run: | node scripts/check-merge-queue-triage-outcome.mjs --self-test node scripts/check-merge-queue-triage-outcome.mjs # Rationale: `scripts/check-merged-branch-reaper-outcome.mjs` header (#13503). - name: Merged-branch reaper classifier contract + id: merged-branch-reaper-classifier-contract + continue-on-error: true run: | node scripts/check-merged-branch-reaper-outcome.mjs --self-test node scripts/check-merged-branch-reaper-outcome.mjs @@ -3268,6 +3483,8 @@ jobs: # dominance experiment lives, since a dev cannot fabricate a real # runner-starved CI run. - name: Shard attestation gate + id: shard-attestation-gate + continue-on-error: true run: pnpm check:shard-attestation # Test Core package selection self-test (#16453). ci.yml's "Compute this @@ -3282,10 +3499,14 @@ jobs: # the absence of the ones that must not fire (an empty docs-only merge # group selects nothing WITHOUT the #10057 complaint). Offline; ~2s. - name: Test Core package selection self-test + id: test-core-package-selection-self-test + continue-on-error: true run: pnpm check:select-shard-packages # Rationale: `scripts/ci/scheduled-full-run.mjs` header (#16467). - name: Hourly full run self-test and concurrency gate + id: hourly-full-run-self-test-and-concurrency + continue-on-error: true run: | node scripts/ci/scheduled-full-run.mjs --self-test node scripts/ci/scheduled-full-run.mjs --check-concurrency @@ -3303,21 +3524,40 @@ jobs: # scoped step, and that the scoped set equals the decided set. Offline; # ~3 s. - name: Gate-family selection self-test + id: gate-family-selection-self-test + continue-on-error: true run: pnpm check:select-gate-families # Rationale: `scripts/check-aggregator-roster.mjs` header (#10490, #22991, #4690). - name: Aggregator roster gate + id: aggregator-roster-gate + continue-on-error: true run: | node scripts/check-aggregator-roster.mjs --self-test node scripts/check-aggregator-roster.mjs + # Rationale: `scripts/ci/lane-verdict.mjs` header (#23191). The verdict + # every lane above and below ends with reads each gate's `outcome`, never + # its `conclusion`; on a green tree the two agree, so a verdict that read + # the wrong one would stay green on every run and miss every red. Only + # its self-test feeds it the shape that tells them apart (a failed gate + # whose conclusion is `success`). Offline fixtures; sub-second. + - name: Lane verdict self-test + id: lane-verdict-self-test + continue-on-error: true + run: node scripts/ci/lane-verdict.mjs --self-test + # Rationale: `scripts/check-required-contexts.mjs` header (#6865, #5617, #5584, # #4928, #9642). - name: Required-context name pin + id: required-context-name-pin + continue-on-error: true run: pnpm check:required-contexts # Rationale: `scripts/check-cross-package-test-inputs.mjs` header (#7802). - name: Cross-package test inputs + id: cross-package-test-inputs + continue-on-error: true run: pnpm check:cross-package-test-inputs # Console-intercept disarm (#10374). vitest's worker forwards every @@ -3338,6 +3578,8 @@ jobs: # that app proves the defect is still live in the installed vitest. # Self-test first, then the real scan; reads ~72 manifests; sub-second. - name: Console-intercept disarm + id: console-intercept-disarm + continue-on-error: true run: node scripts/check-console-intercept-disarm.mjs --self-test && node scripts/check-console-intercept-disarm.mjs # Declared registry log level (#15425, origin #13517). objectql's @@ -3355,6 +3597,8 @@ jobs: # declared would have shipped a known-incomplete list. Self-test first, # then the real scan; reads ~72 manifests and their test sources. - name: Declared registry log level + id: declared-registry-log-level + continue-on-error: true run: node scripts/check-registry-log-declared.mjs --self-test && node scripts/check-registry-log-declared.mjs # The same defect one package down (#15484). `packages/rest`'s own fault @@ -3368,6 +3612,8 @@ jobs: # seam does not recognise (it resolves to the DEFAULT, silently), and the # shipped default itself being lowered to quieten a log. Self-test first. - name: Declared REST fault-log level + id: declared-rest-fault-log-level + continue-on-error: true run: node scripts/check-rest-log-declared.mjs --self-test && node scripts/check-rest-log-declared.mjs # The pairing half of the one above (#17865, ruling batch #128 item 4). @@ -3381,10 +3627,14 @@ jobs: # check reached by a legitimate-looking config line. This gate names an # undeclared observer instead. Self-test first. - name: Declared REST fault-log level — observers + id: declared-rest-fault-log-level-observers + continue-on-error: true run: node scripts/check-rest-log-spy-declared.mjs --self-test && node scripts/check-rest-log-spy-declared.mjs # Rationale: `scripts/check-live-db-isolation.mjs` header (#10382, #9350). - name: Live-server database isolation + id: live-server-database-isolation + continue-on-error: true run: pnpm check:live-db-isolation # Nightly-tier file adoption (#16494). `scripts/nightly-tiers.mjs` moved @@ -3406,11 +3656,15 @@ jobs: # carries that boundary and the measurement behind the detector. # Reads ~80 manifests and a handful of configs; sub-second. - name: Nightly-tier file adoption + id: nightly-tier-file-adoption + continue-on-error: true run: pnpm check:tier-file-adoption # Rationale: `scripts/check-ci-filter-parity.mjs` header (#10379, #7802, #10015, # #9829). - name: CI filter parity (cross-package Layer C) + id: ci-filter-parity-cross-package-layer-c + continue-on-error: true run: | node scripts/check-ci-filter-parity.mjs --self-test node scripts/check-ci-filter-parity.mjs @@ -3431,6 +3685,8 @@ jobs: # reason as the release-rehearsal self-test above: see the GATE INVOCATION # IDIOM note at the top of this file. Pure functions, no IO, milliseconds. - name: Shard partitioner self-test + id: shard-partitioner-self-test + continue-on-error: true run: node scripts/partition-test-shards.mjs --self-test # A self-test that RUNS but cannot fail anything (#17097) — the sibling of @@ -3456,6 +3712,8 @@ jobs: # reason as the steps above: see the GATE INVOCATION IDIOM note at the top # of this file. In-process fixtures, no IO, milliseconds. - name: Test-timing parser self-test + id: test-timing-parser-self-test + continue-on-error: true run: node scripts/report-test-timings.mjs --self-test # The per-package suite-duration ratchet's own instrument (#16468). The @@ -3466,11 +3724,15 @@ jobs: # the GATE INVOCATION IDIOM reason at the top of this file. In-process # fixtures, no IO, milliseconds. - name: Suite-duration ceiling check self-test + id: suite-duration-ceiling-check-self-test + continue-on-error: true run: node scripts/check-test-suite-ceilings.mjs --self-test # Rationale: `scripts/check-declaration-mirrors.mjs` header (#10549, #10513, # #10450). - name: Hand-written declaration mirrors + id: hand-written-declaration-mirrors + continue-on-error: true run: | node scripts/check-declaration-mirrors.mjs --self-test node scripts/check-declaration-mirrors.mjs @@ -3492,6 +3754,8 @@ jobs: # never repairs. Invoked as `node` rather than a `pnpm check:*` alias for the # same reason as the step above (#9465). Reads two files; milliseconds. - name: Published list mirrors + id: published-list-mirrors + continue-on-error: true run: | node scripts/check-published-list-mirrors.mjs --self-test node scripts/check-published-list-mirrors.mjs @@ -3529,14 +3793,20 @@ jobs: # Inventory on demand: `pnpm examples:live-imports`. # Static scan of test sources; sub-second. - name: examples/** live-import inventory + id: examples-live-import-inventory + continue-on-error: true run: pnpm check:examples-live-imports # Rationale: `scripts/check-test-source-alias.mjs` header (#7668, #7849). - name: Test-source alias gate + id: test-source-alias-gate + continue-on-error: true run: pnpm check:test-source-alias # Rationale: `scripts/check-refd-timer-probe.mjs` header (#10785). - name: Ref'd-timer probe containment + id: ref-d-timer-probe-containment + continue-on-error: true run: pnpm check:refd-timer-probe # #4248 packaging-hygiene guard. Without a `files` whitelist npm packs the @@ -3553,10 +3823,14 @@ jobs: # hand-checked. Runs its own --self-test first: the pattern semantics can # be wrong while every package is right. - name: Published-files whitelist guard + id: published-files-whitelist-guard + continue-on-error: true run: pnpm check:published-files # Rationale: `scripts/check-manifest-repository-directory.mjs` header (#15991). - name: Manifest repository.directory guard + id: manifest-repository-directory-guard + continue-on-error: true run: pnpm check:manifest-repository-directory # WHERE-matcher conformance gate (#8494, from #7620). The read-side @@ -3577,6 +3851,8 @@ jobs: # fine, and a scan that quietly stops matching would report OK while # reading nothing (#4868's family). - name: WHERE-matcher conformance gate + id: where-matcher-conformance-gate + continue-on-error: true run: pnpm check:where-matcher # ObjectQL double `limit` gate (#11525, from #10978). The third member of @@ -3599,6 +3875,8 @@ jobs: # is fine, and a scan that quietly stops matching would report OK while # reading nothing (#4868's family). - name: ObjectQL double limit gate + id: objectql-double-limit-gate + continue-on-error: true run: pnpm check:objectql-double-limit # Paired kernel-hook pin gate (#5282, from #5170 / #5257 / #5274). The two @@ -3625,6 +3903,8 @@ jobs: # (#4868's family). Measured against main's corpus before being pinned # here: 4 dispatched hooks, 0 problems. - name: Paired kernel-hook pin gate + id: paired-kernel-hook-pin-gate + continue-on-error: true run: pnpm check:kernel-hook-pairs # Plugin teardown-shape gate (#10619, from #9371 / #10371). `Plugin` @@ -3664,6 +3944,8 @@ jobs: # Static AST over ~4,400 sources plus one `git show` and two spawns; # 3.4s + 2.5s measured locally, no build needed, so it belongs in this job. - name: Plugin teardown-shape gate + id: plugin-teardown-shape-gate + continue-on-error: true run: | node scripts/check-plugin-teardown-shape.mjs --self-test node scripts/check-plugin-teardown-shape.mjs @@ -3684,11 +3966,15 @@ jobs: # every descriptor is fine, and a scan that stops matching would report OK # while reading nothing (#4868's family). - name: Resume-authority declaration gate + id: resume-authority-declaration-gate + continue-on-error: true run: pnpm check:resume-authority-declared # Rationale: `scripts/check-driver-memory-census.mjs` header (#6664, #5704, #5499, # #5784, #6468, #6553, #4868). - name: driver-memory census gate + id: driver-memory-census-gate + continue-on-error: true run: pnpm check:driver-memory-census # Merge-driver wiring gate (#6008, from #4675 / #4868). `merge=os-regen` @@ -3716,6 +4002,8 @@ jobs: # (#4690's family: a gate archived as "runs elsewhere" that ran nowhere). # Pure git + fs, no build, seconds — so it belongs in this job. - name: Merge-driver wiring gate + id: merge-driver-wiring-gate + continue-on-error: true run: pnpm check:merge-driver # ADR-0122 type-alias convention gate (#5551). `packages/spec` spelled the @@ -3734,19 +4022,27 @@ jobs: # and a scan that stops matching would report OK while reading nothing # (#4868's family). - name: Spec type-alias convention gate (ADR-0122) + id: spec-type-alias-convention-gate-adr-0122 + continue-on-error: true run: pnpm check:spec-parsed-alias # Rationale: `scripts/check-tenant-chokepoint.mjs` header (#6792, #3724, #6577, # #2734, #3623, #4690). - name: Read-side tenant chokepoint gate + id: read-side-tenant-chokepoint-gate + continue-on-error: true run: pnpm check:tenant-chokepoint # Rationale: `scripts/pnpm-filter-targets.mjs` header (#10853, #4690). - name: Every committed pnpm --filter names a real package + id: every-committed-pnpm-filter-names-a-real + continue-on-error: true run: pnpm check:pnpm-filter-targets # Rationale: `scripts/check-agent-test-spelling.mjs` header (#10166, #11425). - name: No committed command forwards args to vitest through a bare -- + id: no-committed-command-forwards-args-to-vitest + continue-on-error: true run: pnpm check:agent-test-spelling # Every `#` override in turbo.json must name a package the @@ -3784,11 +4080,15 @@ jobs: # first: this gate's defect class is its matching rule, which a clean tree # cannot distinguish from a rule that stopped matching. - name: turbo.json package tasks name real packages and real scripts + id: turbo-json-package-tasks-name-real-packages + continue-on-error: true run: pnpm check:turbo-task-graph # Rationale: `scripts/check-workspace-manifest-cycles.mjs` header (#13513, # #14195). - name: workspace manifest dependency graph has no cycle + id: workspace-manifest-dependency-graph-has + continue-on-error: true run: pnpm check:workspace-manifest-cycles # Merged-result budget probe, wired by the card that wired the citation @@ -3802,6 +4102,8 @@ jobs: # self-test is the only instrument that can, and an instrument nobody runs # is silence. - name: Merged-result probe self-test + id: merged-result-probe-self-test + continue-on-error: true run: pnpm check:merged-result # Issue citations (#17512, wired by #18224). TWO commands, and the split @@ -3877,6 +4179,8 @@ jobs: # unresolvable base, which used to throw an unclassified exit code — # exits PREREQUISITE NOT MET rather than reporting a clean tree. - name: Issue citations this change adds resolve on the board + id: issue-citations-this-change-adds-resolve + continue-on-error: true env: # The board read is one GET per distinct number the change cites # (none at all when it cites none), against a PUBLIC board. Spelled @@ -3907,27 +4211,24 @@ jobs: # would defeat exactly that, so this step goes immediately above it # instead (#10572: no existing step's relative order moves). - name: 'Unquoted workflow step names do not silently truncate at " #" (#15149)' + id: unquoted-workflow-step-names-do-not-silently + continue-on-error: true run: pnpm check:workflow-step-name-quoting # Rationale: `packages/spec/scripts/check-duration-unit-keys.ts` header (#14478). - name: Duration-shaped spec keys carry their unit in the key name + id: duration-shaped-spec-keys-carry-their-unit + continue-on-error: true run: pnpm --filter @objectstack/spec check:duration-unit-keys - # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The - # reporter finds its own job by runner name, so each lane counts its own - # tail. Argued in full at the end of `lint-eslint`. ────────────────────── - - name: Unmeasured-gate-tail reporter self-test - if: failure() - run: node scripts/report-unmeasured-gate-tail.mjs --self-test - - # Runs only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-scoped-erasure: # Lane 5 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -3945,9 +4246,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - # The unmeasured-tail reporter at the end of this lane: `lint-eslint` - # argues the scope. - actions: read steps: - name: Checkout repository @@ -4066,6 +4364,8 @@ jobs: # Scoped (#16496): the same population and inputs as the slot-lookup # ratchet, with `scripts/query-options-erasure-baseline.json`. - name: Engine query-options erasure ratchet + id: engine-query-options-erasure-ratchet + continue-on-error: true if: steps.gate-families.outputs.query_options_erasure != 'skip' run: pnpm check:query-options-erasure @@ -4095,24 +4395,19 @@ jobs: # confined to product source, tests, docs or changesets skips it here and # pays for it on `push` to main and on the hourly run. - name: A declared gate population reaches the tree + id: a-declared-gate-population-reaches-the-tree + continue-on-error: true if: steps.gate-families.outputs.declared_population_live != 'skip' run: pnpm check:declared-population-live - # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The - # reporter finds its own job by runner name, so each lane counts its own - # tail. Argued in full at the end of `lint-eslint`. ────────────────────── - - name: Unmeasured-gate-tail reporter self-test - if: failure() - run: node scripts/report-unmeasured-gate-tail.mjs --self-test - - # Runs only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-scoped-slot-corpus: # Lane 6 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -4128,9 +4423,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - # The unmeasured-tail reporter at the end of this lane: `lint-eslint` - # argues the scope. - actions: read steps: - name: Checkout repository @@ -4189,6 +4481,8 @@ jobs: # `eslint.config.mjs` and the top-level `scripts/*.mjs` it imports; a # group touching none of those skips it (selection step above). - name: Slot-lookup ratchet + id: slot-lookup-ratchet + continue-on-error: true if: steps.gate-families.outputs.slot_lookup != 'skip' run: pnpm check:slot-lookup @@ -4219,6 +4513,8 @@ jobs: # unconditional, and the ruling that authorizes moving a tooling # self-test off the PR path is quoted in the selector's header. - name: scripts/ entry guards go through one predicate + id: scripts-entry-guards-go-through-one + continue-on-error: true if: steps.gate-families.outputs.entry_guard != 'skip' run: pnpm check:entry-guard @@ -4264,24 +4560,19 @@ jobs: # extensions (and no root configuration) skips it -- selection step at # the top of this job. - name: Comment mask agrees with a real parser over the whole corpus + id: comment-mask-agrees-with-a-real-parser + continue-on-error: true if: steps.gate-families.outputs.comment_mask_corpus != 'skip' run: node scripts/check-comment-mask-corpus.mjs - # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The - # reporter finds its own job by runner name, so each lane counts its own - # tail. Argued in full at the end of `lint-eslint`. ────────────────────── - - name: Unmeasured-gate-tail reporter self-test - if: failure() - run: node scripts/report-unmeasured-gate-tail.mjs --self-test - - # Runs only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-scoped-tooling: # Lane 7 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -4297,9 +4588,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - # The unmeasured-tail reporter at the end of this lane: `lint-eslint` - # argues the scope. - actions: read steps: - name: Checkout repository @@ -4376,6 +4664,8 @@ jobs: # the whole battery. Gate weakening is a maintainer floor; the sentence # that authorizes this one is quoted in the selector's header. - name: PM bare-root worklist self-test + id: pm-bare-root-worklist-self-test + continue-on-error: true if: steps.gate-families.outputs.bare_root_worklist != 'skip' run: node scripts/pm/bare-root-worklist.mjs --self-test @@ -4427,6 +4717,8 @@ jobs: # those skips it here and runs it on `push` to main and on the hourly # full run. - name: Self-test workflow-command gate + id: self-test-workflow-command-gate + continue-on-error: true if: steps.gate-families.outputs.self_test_workflow_commands != 'skip' run: | node scripts/check-self-test-workflow-commands.mjs --self-test @@ -4474,24 +4766,19 @@ jobs: # this job runs this step on every doubt, and `push` on main keeps it # unconditional. - name: Verify-lock entry-point self-test + id: verify-lock-entry-point-self-test + continue-on-error: true if: steps.gate-families.outputs.verify_lock != 'skip' run: bash scripts/pm/os-verify-lock.sh --self-test - # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The - # reporter finds its own job by runner name, so each lane counts its own - # tail. Argued in full at the end of `lint-eslint`. ────────────────────── - - name: Unmeasured-gate-tail reporter self-test - if: failure() - run: node scripts/report-unmeasured-gate-tail.mjs --self-test - - # Runs only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-pm-dispatch-gates: # Lane 8 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -4510,9 +4797,6 @@ jobs: timeout-minutes: 50 permissions: contents: read - # The unmeasured-tail reporter at the end of this lane: `lint-eslint` - # argues the scope. - actions: read steps: - name: Checkout repository @@ -4642,24 +4926,19 @@ jobs: # this family -- is again a maintainer call, taken in # scripts/ci/select-gate-families.sh under its self-test. - name: PM dispatch-gates self-test + id: pm-dispatch-gates-self-test + continue-on-error: true if: steps.gate-families.outputs.pm_dispatch_gates != 'skip' run: pnpm check:pm-dispatch-gates - # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The - # reporter finds its own job by runner name, so each lane counts its own - # tail. Argued in full at the end of `lint-eslint`. ────────────────────── - - name: Unmeasured-gate-tail reporter self-test - if: failure() - run: node scripts/report-unmeasured-gate-tail.mjs --self-test - - # Runs only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs # Lane 1 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the @@ -4834,6 +5113,8 @@ jobs: # dist/*.d.ts and therefore lives after the build step, near the bottom of # this job ("Re-measure the type-check DEBT / TEST_DEBT ledger"). - name: Check every package is type-check covered or ledgered + id: check-every-package-is-type-check-covered-or + continue-on-error: true run: pnpm check:type-check-coverage # Conformance meta-gate (#4363): `spec/src/data/*-conformance.ts` holds the @@ -4847,11 +5128,15 @@ jobs: # CLASSIFIED direction so a NEW shared fixture cannot start life uncovered. # Reads source files only; no build, sub-second. - name: Check every driver runs the shared conformance cases + id: check-every-driver-runs-the-shared + continue-on-error: true run: pnpm check:driver-conformance # Rationale: `scripts/check-object-def-param-keys.mjs` header (#16711, #4311, # #16570). - name: Check object-definition parameters declare the keys they are read for + id: check-object-definition-parameters-declare + continue-on-error: true run: pnpm check:object-def-param-keys # Stall-guard self-test (#4250). scripts/run-with-stall-guard.mjs is what @@ -4871,10 +5156,14 @@ jobs: # healthy run still propagates its own exit status and steady output is # never called a stall. ~50s, no build, no network. - name: Stall-guard self-test + id: stall-guard-self-test + continue-on-error: true run: pnpm check:stall-guard # Rationale: `scripts/check-stall-guard-budget.mjs` header (#11916). - name: Stall-guard budget invariant + id: stall-guard-budget-invariant + continue-on-error: true run: pnpm check:stall-guard-budget # The `Rerun Safety` verdict table (#18590) — self-test half only, and the @@ -4895,11 +5184,15 @@ jobs: # only instrument watching them. Reads nothing but its own fixtures; no # build, no network, no token, sub-second. - name: Rerun-safety verdict table self-test + id: rerun-safety-verdict-table-self-test + continue-on-error: true run: pnpm check:rerun-safety-verdict # Rationale: `scripts/measure-stall-guard-headroom.mjs` header (#13246, #13121). # ⛔ `--self-test` only: never append a bare run (it needs a token). - name: Stall-guard headroom self-test (self-test half only, never the measurement) + id: stall-guard-headroom-self-test-self-test + continue-on-error: true run: pnpm check:stall-guard-headroom # The #12981 swallow census's own controls (#13919, maintainer ruling of @@ -4949,6 +5242,8 @@ jobs: # not a gate" banner. It reads `packages/**` source text through ts-parse; # no network, no token, no build. - name: Swallow-census control families (gated families only, never POSITIVE) + id: swallow-census-control-families-gated + continue-on-error: true run: pnpm check:swallow-census-controls # The dev-preflight gate's self-test — and deliberately only HALF of that @@ -4993,9 +5288,13 @@ jobs: # script would drag the scan half in with it — the same shape as the `dev` # chain calling this file directly (see the script's header). - name: Self-test the dev-prereqs gate (self-test half only, never the scan) + id: self-test-the-dev-prereqs-gate-self-test + continue-on-error: true run: node scripts/check-dev-prereqs.mjs --self-test - name: Type check (@objectstack/spec) + id: type-check-objectstack-spec + continue-on-error: true run: pnpm --filter @objectstack/spec exec tsc --noEmit # Meta-gate (#4203): the `check:generated` aggregate reconciles its @@ -5014,6 +5313,8 @@ jobs: # this job is unfiltered and required, so the meta-gate cannot go dormant. # Reads package.json only; no build, sub-second. - name: "Check every check:/gen: script is classified in the check:generated ledger" + id: check-every-check-gen-script-is-classified + continue-on-error: true run: pnpm --filter @objectstack/spec check:generated --reconcile-only # The last four artifact gates, moved here from ci.yml's `check-generated` @@ -5047,6 +5348,8 @@ jobs: # `check:authorable-surface` step below, whose `--check` mode writes the # gitignored json-schema/ tree and refuses to touch a tracked file (#4711). - name: Check skill docs are generated from SKILL.md frontmatter + id: check-skill-docs-are-generated-from-skill-md + continue-on-error: true run: pnpm --filter @objectstack/spec check:skill-docs # [#10096] The schema-free `/meta` URL-spelling data module @@ -5059,6 +5362,8 @@ jobs: # needs no build; belongs in this unfiltered required job for the same # reason as its artifact-gate neighbours. - name: Check the meta-url-spelling data module is current and spellings agree + id: check-the-meta-url-spelling-data-module + continue-on-error: true run: pnpm --filter @objectstack/spec check:meta-url-spelling # The two ADR-0087 D4 projections, under the #22449 B′ ruling. They are @@ -5069,9 +5374,13 @@ jobs: # request that only adds a registry entry needs no regeneration commit. # Like their neighbours they read source via tsx and need no build. - name: Check spec-changes.json generates from the ADR-0087 registries + id: check-spec-changes-json-generates + continue-on-error: true run: pnpm --filter @objectstack/spec check:spec-changes - name: Check the protocol upgrade guide generates from the ADR-0087 registries + id: check-the-protocol-upgrade-guide-generates + continue-on-error: true run: pnpm --filter @objectstack/spec check:upgrade-guide # The ruling's condition (1): the review value the committed copies carried @@ -5084,12 +5393,16 @@ jobs: # Red only when a side cannot be generated; a diff is never red. Measured # locally at ~6 s, no build needed. - name: Render the generated spec-changes and upgrade-guide diff against the base + id: render-the-generated-spec-changes + continue-on-error: true run: >- pnpm --filter @objectstack/spec exec tsx scripts/render-projection-diff.ts --base HEAD^1 --out-dir "$RUNNER_TEMP/spec-projections" --summary "$GITHUB_STEP_SUMMARY" # Written only when the diff is non-empty, so an unchanged run uploads nothing. - name: Upload the generated spec-projections diff + id: upload-the-generated-spec-projections-diff + continue-on-error: true uses: actions/upload-artifact@v7 with: name: spec-projections-diff @@ -5101,6 +5414,8 @@ jobs: # #4642). # Needs no build (reads `src/` via tsx), so it runs before the workspace build. - name: Check the export-origins baseline resolves as recorded + id: check-the-export-origins-baseline-resolves + continue-on-error: true run: pnpm --filter @objectstack/spec check:export-origins # The authorable KEY surface — what a metadata author may write, which for @@ -5119,6 +5434,8 @@ jobs: # refuses on a missing or stale tree, so a reordering fails loudly with the # prescription rather than reporting on last run's schemas. - name: Check the authorable key surface is recorded and nothing vanished + id: check-the-authorable-key-surface-is-recorded + continue-on-error: true run: pnpm --filter @objectstack/spec check:authorable-surface # Generated-docs gate: content/docs/references/** is generated from the spec @@ -5139,6 +5456,8 @@ jobs: # this step with that: the duplicate ~1600-schema generation, and the tracked # files a `check:` script had no business writing. - name: Check generated reference docs are in sync with the spec + id: check-generated-reference-docs-are-in-sync + continue-on-error: true run: pnpm --filter @objectstack/spec check:docs # Same class, same reasoning, different surface: skills/*/references/_index.md @@ -5161,10 +5480,14 @@ jobs: # that silent skip is what let the map keep pointing at data/dataset.zod.ts for a # year after #1620 renamed it to data/seed.zod.ts. - name: Check generated skill references are in sync with the spec + id: check-generated-skill-references-are-in-sync + continue-on-error: true run: pnpm --filter @objectstack/spec check:skill-refs # Rationale: `scripts/check-skill-frame-sync.mjs` header (#5130, #5451, #5799). - name: Check the decision frame is in sync across its four copies + id: check-the-decision-frame-is-in-sync-across + continue-on-error: true run: pnpm check:skill-frame-sync # The SECOND skill-frame gate — and deliberately only HALF of it (#8110). @@ -5201,11 +5524,15 @@ jobs: # `dev` chain calling check-dev-prereqs.mjs directly rather than through its # pnpm script (see that script's header). - name: Self-test the skill-frame freshness gate (self-test half only, never the scan) + id: self-test-the-skill-frame-freshness-gate + continue-on-error: true run: node scripts/check-skill-frame-freshness.mjs --self-test # Rationale: `scripts/check-skill-compatibility-version.mjs` header (#5331, #5245, # #4690). - name: Check SKILL.md compatibility declarations match the workspace majors + id: check-skill-md-compatibility-declarations + continue-on-error: true run: pnpm check:skill-compatibility # The fifth reader of skills/objectstack-platform/SKILL.md, and the first @@ -5224,10 +5551,14 @@ jobs: # so it cannot go dormant on the PR that adds a top-level key without # touching the skill — which is exactly how the four went missing. - name: Check the platform skill enumerates every authorable top-level stack key + id: check-the-platform-skill-enumerates + continue-on-error: true run: pnpm check:skill-top-level-keys # Rationale: `scripts/check-agent-model-declared.mjs` header (#6803, #6686). - name: Check every agent definition declares a model + id: check-every-agent-definition-declares + continue-on-error: true run: pnpm check:agent-model-declared # ADR-0049 tombstones, refusal strings and doc pages name the release that @@ -5266,20 +5597,28 @@ jobs: # The `--self-test` invocation is what `check:self-test-wired` requires; the pnpm # script runs it before the production pass. - name: Check no prose dates a change to an unreleased `@objectstack/spec` major + id: check-no-prose-dates-a-change + continue-on-error: true run: pnpm check:future-spec-major # Rationale: `scripts/check-ratchet-remedy-authority.mjs` header (#8435, #8517, # #8539, #8549, #8538). - name: Ratchet-remedy authority convention (#8435) across the gate farm + id: ratchet-remedy-authority-convention-across + continue-on-error: true run: pnpm check:ratchet-remedy-authority # Rationale: `scripts/check-skills-token-ratchet.mjs` header (#10473, #10402). - name: Published skills token ratchet (#10473) + its self-test + id: published-skills-token-ratchet-its-self-test + continue-on-error: true run: | node scripts/check-skills-token-ratchet.mjs --self-test node scripts/check-skills-token-ratchet.mjs - name: Check the react-blocks contract is in sync with the spec + id: check-the-react-blocks-contract-is-in-sync + continue-on-error: true run: pnpm --filter @objectstack/spec check:react-blocks # The shipped `objectstack.manifest.json` files declare TemplateManifestSchema @@ -5294,11 +5633,15 @@ jobs: # for the standard reason: a filter on packages/create-objectstack/** would go # dormant on exactly the PR that tightens the schema instead. - name: Check every shipped template manifest satisfies TemplateManifestSchema + id: check-every-shipped-template-manifest + continue-on-error: true run: pnpm --filter @objectstack/spec check:template-manifests # Rationale: `packages/spec/scripts/check-objectui-pin-citations.ts` header # (#10274, #9881, #9972, #9397, #10137, #5010). - name: Check spec pin citations still name the pin this repo builds against + id: check-spec-pin-citations-still-name-the-pin + continue-on-error: true run: pnpm --filter @objectstack/spec check:objectui-pin-citations # #11344. `packages/spec/llms.txt` is hand-kept, has no generator, and @@ -5316,8 +5659,19 @@ jobs: # every schema domain, so a PR that adds a package or a schema anywhere # can falsify this file without touching packages/spec/**. - name: Check the shipped llms.txt still describes this package + id: check-the-shipped-llms-txt-still-describes + continue-on-error: true run: pnpm --filter @objectstack/spec check:llms-txt + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} + env: + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs + # Lane 2 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the # contract, and is the thing the merge queue actually requires — read it @@ -5446,6 +5800,8 @@ jobs: # against the real fumadocs/MDX pipeline) and on why this is a COMPARE rather # than a stamp. Runs its own --self-test first, through the alias. - name: CLI transcripts in content/docs match the registry they quote + id: cli-transcripts-in-content-docs-match + continue-on-error: true run: pnpm check:docs-transcript-drift # The per-package `typecheck` scripts the coverage gate above requires @@ -5479,6 +5835,8 @@ jobs: # `--filter` runs the whole workspace. Selection decides which packages # run; the package-local turbo cache still decides which execute. - name: Type check workspace packages + id: type-check-workspace-packages + continue-on-error: true env: OS_SHARD_EVENT_NAME: ${{ github.event_name }} OS_SHARD_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} @@ -5556,6 +5914,15 @@ jobs: path: .turbo/cache key: ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}-${{ github.sha }} + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} + env: + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs + # Lane 3 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the # contract, and is the thing the merge queue actually requires — read it @@ -5737,6 +6104,8 @@ jobs: # nothing is checking (#7668, #12078). No `dist/` anywhere is exit 3 # PREREQUISITE NOT MET, never a pass. - name: Sweep the built closure for vanished declarations + id: sweep-the-built-closure-for-vanished + continue-on-error: true run: pnpm check:dts-closure # ⚠️ `NODE_OPTIONS` here is HALF of the heap ceiling this gate runs tsc @@ -5764,10 +6133,21 @@ jobs: # reverse. The constant's comment carries the full reading and both # directions are pinned in that file's `--self-test`. - name: Re-measure the type-check DEBT / TEST_DEBT ledger + id: re-measure-the-type-check-debt-test-debt + continue-on-error: true env: NODE_OPTIONS: --max-old-space-size=6144 run: pnpm check:type-check-debt + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} + env: + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs + # Lane 4 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the # contract, and is the thing the merge queue actually requires — read it @@ -5874,9 +6254,13 @@ jobs: # Same sweep as `typecheck-debt`'s step of this name (#15042, #7668, #12078). - name: Sweep the built closure for vanished declarations + id: sweep-the-built-closure-for-vanished + continue-on-error: true run: pnpm check:dts-closure - name: Type check example apps + id: type-check-example-apps + continue-on-error: true run: pnpm --filter './examples/*' run typecheck # Backward-compatibility gate: a frozen third-party-style consumer @@ -5884,6 +6268,8 @@ jobs: # spec change — a red typecheck here means the spec dropped/narrowed an # export a published-spec third party already uses. See the package README. - name: Type check downstream consumer contract + id: type-check-downstream-consumer-contract + continue-on-error: true run: pnpm --filter @objectstack/downstream-contract run typecheck # Public API-surface gate (#2035): the spec package IS the third-party API. @@ -5926,9 +6312,13 @@ jobs: # directions — a text scanner over prose fails by quietly matching # nothing, and only a positive control tells that apart from a clean tree. - name: Published-README export gate + id: published-readme-export-gate + continue-on-error: true run: pnpm check:published-readme-exports - name: Check @objectstack/spec public API surface + id: check-objectstack-spec-public-api-surface + continue-on-error: true run: pnpm --filter @objectstack/spec run check:api-surface # [#11350] Consumer-shaped declaration-emit pin against the BUILT root @@ -5945,6 +6335,8 @@ jobs: # the surface a consumer actually installs. Adds no required context — # a step in an existing lane (#9325). - name: Root-entry type nameability pin (built dist, declaration emit) + id: root-entry-type-nameability-pin-built-dist + continue-on-error: true env: OS_EXPECT_ROOT_NAMEABILITY: '1' run: pnpm --filter @objectstack/spec exec vitest run scripts/root-entry-type-nameability.pin.test.ts @@ -5969,6 +6361,8 @@ jobs: # above and refuses (never skips) on a stale or unbuilt tree. Adds no # required context: a step in an existing lane (#9325). - name: Check public-entry type nameability (built dist, declaration emit) + id: check-public-entry-type-nameability-built + continue-on-error: true run: pnpm --filter @objectstack/spec run check:entry-nameability # Same surface, the other axis: api-surface/ records that an export @@ -5982,6 +6376,8 @@ jobs: # with the other consumer gates. Self-tests first — a scan whose green # result is "nothing found" has to prove it can still find something. - name: Check no exported spec type resolves to `any` + id: check-no-exported-spec-type-resolves-to-any + continue-on-error: true run: pnpm --filter @objectstack/spec run check:exported-any # [#11927] The same question, asked of the SDK boundary instead of the @@ -6011,6 +6407,8 @@ jobs: # # Adds no required context — a step in an existing lane (#9325). - name: Check no exported client callable resolves to `any` + id: check-no-exported-client-callable-resolves + continue-on-error: true run: pnpm --filter @objectstack/client run check:exported-any-returns # Third axis on the same surface: api-surface/ shows a name on two @@ -6022,6 +6420,8 @@ jobs: # (dual-source-exports.baseline.json), so only a NEW one fails (#4446). # Self-tests first, like exported-any. - name: Check no new same-name dual-source spec exports + id: check-no-new-same-name-dual-source-spec + continue-on-error: true run: pnpm --filter @objectstack/spec run check:dual-source-exports # [#10199] The mechanized form of the 2026-08-20 ruling on #10096: @@ -6056,6 +6456,8 @@ jobs: # transitive walk that keeps the gate honest the day `splitting` is turned # on can only be exercised against fixtures. - name: Check declared browser-reachable spec entries link no zod + id: check-declared-browser-reachable-spec + continue-on-error: true run: pnpm --filter @objectstack/spec run check:browser-reachable-entries # Anti-drift for the skill EXAMPLES, not just the skill reference indexes @@ -6069,16 +6471,22 @@ jobs: # fails here instead of in a third party's editor. Reads the built dist, # so it runs after the build step alongside the other consumer gates. - name: Check skills TypeScript examples compile + id: check-skills-typescript-examples-compile + continue-on-error: true run: pnpm --filter @objectstack/spec run check:skill-examples # Rationale: `packages/lint/scripts/check-doc-formula-expressions.mjs` header # (#5116, #5026). - name: Check docs formula examples are valid CEL + id: check-docs-formula-examples-are-valid-cel + continue-on-error: true run: pnpm --filter @objectstack/lint run check:doc-formula-expressions # Rationale: `packages/lint/scripts/check-doc-security-posture.mjs` header # (#10618, #10581, #10714). - name: Check docs object examples pass the os validate security posture + id: check-docs-object-examples-pass-the-os + continue-on-error: true run: pnpm --filter @objectstack/lint run check:doc-security-posture # The YAML half of the same surface (#13086, ruled 2026-08-29). @@ -6097,6 +6505,8 @@ jobs: # cluster above, not a build dependency. Self-tests first, like its # neighbours. - name: Check docs YAML examples parse against live spec schemas + id: check-docs-yaml-examples-parse-against-live + continue-on-error: true run: pnpm --filter @objectstack/spec run check:yaml-examples # Same anti-drift class as the gates above, for the generated translation @@ -6124,6 +6534,8 @@ jobs: # Reads the built @objectstack/spec dist through the extract configs, so # it belongs after the build step with the other consumer gates. - name: Check generated translation bundles are in sync with the schema + id: check-generated-translation-bundles + continue-on-error: true run: pnpm check:i18n # Ratchet on the OTHER i18n question. The step above asks "are the @@ -6139,6 +6551,8 @@ jobs: # # Runs the built CLI over each config, so it also belongs after the build. - name: Check no new untranslated declared labels + id: check-no-new-untranslated-declared-labels + continue-on-error: true run: pnpm check:i18n-coverage # The i18n question none of its three neighbours can answer, because it @@ -6160,6 +6574,8 @@ jobs: # walker) rather than from source text, so it belongs here with the other # post-build consumer gates. - name: Check every declared translation group is walked by the extractor + id: check-every-declared-translation-group + continue-on-error: true run: pnpm check:i18n-walk-parity # The THIRD i18n question, and the one neither step above can answer @@ -6178,8 +6594,19 @@ jobs: # Imports the BUILT output of ten workspace packages, so it belongs here # with the other post-build consumer gates. - name: Check runtime-merged app navigation is translated in every locale + id: check-runtime-merged-app-navigation + continue-on-error: true run: pnpm check:app-nav-i18n + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} + env: + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs + # ──────────────────────────────────────────────────────────────────────── # The required context `TypeScript Type Check`, as an AGGREGATOR (#10471). # ──────────────────────────────────────────────────────────────────────── diff --git a/AGENTS.md b/AGENTS.md index 1f36e48e565..b1c1f158752 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -716,8 +716,8 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d `packages/spec` has **six** checked-in generated artifacts, each with its own CI gate. All of them live in one job — `TypeScript Type Check` in `lint.yml`, which is required and has no paths filter, so no gate can go dormant on the PR that breaks it. -That job runs its gates **sequentially**, so the first stale artifact masks every one -behind it. Match the change to the gate and regenerate up front: +Its lanes run every gate even after an earlier one is red, so one run reports every stale +artifact, each still a CI round-trip. Match the change to the gate and regenerate up front: | You changed | Gate that fails | Regenerate with `pnpm --filter @objectstack/spec …` | |:---|:---|:---| diff --git a/scripts/report-unmeasured-gate-tail.mjs b/scripts/report-unmeasured-gate-tail.mjs deleted file mode 100644 index ebbcc88edec..00000000000 --- a/scripts/report-unmeasured-gate-tail.mjs +++ /dev/null @@ -1,1000 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * report-unmeasured-gate-tail -- turn `Lint & Repo Gates`' abort into a NUMBER. - * - * node scripts/report-unmeasured-gate-tail.mjs # in-job, on failure - * node scripts/report-unmeasured-gate-tail.mjs --self-test # offline, no token - * - * ## What this is for - * - * The `lint` job in `.github/workflows/lint.yml` runs its gates as a sequence of - * separate Actions steps and stops at the first non-zero exit. That is - * DELIBERATE and adjudicated -- see the `GATE ORDER IS DELIBERATE` note above - * `jobs:` in that file, which also names the quantity it is tuned to shrink: - * "the unmeasured tail". This script is the thing that finally prints that - * quantity. - * - * The consequence of aborting is not a smaller signal, it is a DIFFERENT one: - * - * one red gate is a LOWER BOUND on the number of problems in the tree, - * never a count. - * - * A gate that never executed produced no output, and no output is the shape a - * passing gate also has. Reading the tail as "the rest passed" is the zero this - * repo keeps refusing elsewhere: a probe that cannot answer "yes" returns NOT - * MEASURED, not "none". - * - * ## The premise this was built on, measured on three surfaces - * - * The originating card said the abort is invisible -- that "nothing in the - * output says they did not run". Measured against run 34468505490 (job - * 102842713265, `Lint & Repo Gates`, failed at step #55 of 169), that claim is - * TOO STRONG in one place and exactly right in two: - * - * Actions Web UI / jobs API `steps[]` - * SAYS IT, one row at a time: 115 entries came back with - * `conclusion: "skipped"`. It does NOT count them, and `"skipped"` is the - * same word the API uses for a step its own `if:` turned off -- so the - * surface that does say it cannot tell the two apart. A reader wanting - * the number counts grey rows by hand. - * - * check-run `output` - * SAYS NOTHING: `output.title`, `output.summary` and `output.text` were - * all null; the only content was `annotations_count: 1`, the failing - * gate's own annotation. `GITHUB_STEP_SUMMARY` appears zero times in - * lint.yml, so there is no writer for that surface at all. - * - * job log (what an API/agent reader consumes) - * SAYS NOTHING: the log ends with the failing gate's output, then - * `Process completed with exit code 1`, then `Post job cleanup`. Not one - * line about the 115 steps behind it. - * - * So the narrowed, honest defect is: **no surface anywhere carries a COUNT, and - * the two surfaces read by API and by agents carry nothing at all.** This script - * writes the count to all three. - * - * ## Why it reads the API instead of counting locally - * - * The tail's size is `declared - reached`, and only the runner knows `reached`. - * Nothing inside a step exposes which step index is executing, so the count has - * to come from the jobs API for this run. The job therefore needs - * `actions: read`, which is the one permission this adds. - * - * The pre/post boundary in that response is the reading already pinned by - * `scripts/pm/ci-failure.mjs` (`stepsIntegrity`): a job's `steps[]` carries ONE - * numbering gap, at the transition into the runner's reserved `Post ` - * block, and that gap is the normal shape rather than truncation. This file - * re-spells the boundary rather than importing it, to keep the lint job's - * failure reporter off the PM tooling's import graph -- `ci-failure.mjs` pulls - * in `check-governed-merges.mjs`, and a reporter that runs on every red run - * should not go dark because a PM tool moved. - * - * ## What it deliberately does NOT do - * - * * It does not make the skipped gates run. That would undo the adjudicated - * fail-fast ordering and, worse, is the shape that produces cascading false - * reds if the gates ever do depend on each other. - * * It does not use `continue-on-error`, on itself or on anything else. - * Trading a red for a warning is the failure mode this whole idea exists to - * avoid; the job's conclusion is untouched. - * * It never fails the job. It runs only when the job is ALREADY failed, so - * its own non-zero exit could only add a second, misleading annotation. When - * it cannot measure, it says NOT MEASURED and names the reason -- which is - * the whole point -- and still exits 0. - * * It emits exactly ONE `notice` annotation, never a `warning` and never an - * `error`. A notice on an already-failed check cannot be read as a claim - * that something passed, and cannot be read as a downgrade of the red. - * - * ## Cost on a green run - * - * Zero. The step carries `if: failure()`, so on a green head it is skipped: no - * log bytes, no summary bytes, no annotation. The only residue is one more row - * in the job's step list, which is a property of the job, not of its output. - */ - -import process from 'node:process'; -import { appendFileSync, readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { isEntrypoint } from './invoked-as.mjs'; - -/** The verdict `selfTest()` returns when it reached its own end. */ -const SELF_TEST_VERDICT = 'report-unmeasured-gate-tail:self-test:complete'; - -/** - * Steps the RUNNER injects, which are not gates and must not be counted as - * such. `Set up job` opens every job; the `Post ` block plus - * `Complete job` closes it. No declared step in the `lint` job collides with - * either spelling -- asserted in the self-test. - */ -const INJECTED_HEAD = 'Set up job'; -const POST_STEP = /^Post /; - -/** - * Split one job's `steps[]` into the declared region and the runner's tail - * block. - * - * The boundary is the first `Post ` step; `Complete job` sits behind it - * and so needs no rule of its own. Returning the index rather than a filtered - * array keeps the caller's positions aligned with the API's `number` field. - * - * @param {Array>} steps - * @returns {{ declared: Array>, postIndex: number }} - */ -export function declaredRegion(steps) { - const list = Array.isArray(steps) ? steps : []; - const postIndex = list.findIndex((s) => POST_STEP.test(String(s?.name ?? ''))); - const end = postIndex === -1 ? list.length : postIndex; - const declared = list.slice(0, end).filter((s) => String(s?.name ?? '') !== INJECTED_HEAD); - return { declared, postIndex }; -} - -/** The two conclusions that PROVE a step executed. Everything else does not. */ -const EXECUTED = new Set(['success', 'failure']); - -/** - * Find the step to measure the tail FROM, without trusting the one field that - * is still in flight when this runs. - * - * Three cases, in order, and the third is a REFUSAL rather than a pick: - * - * 1. A declared step carries `conclusion: "failure"`. Final, unambiguous, - * and the only case this file had before -- unchanged. - * - * 2. No step carries it, but the step named by `OS_TAIL_REPORT_STEP` -- this - * reporter -- is itself recorded `in_progress`, and exactly ONE other - * declared step is too. Then that step is the failure, by position: - * - * * this reporter's step declares `if: failure()` in the workflow, so - * its running at all is the runner's own statement that a step in - * this job failed. That wiring is not assumed -- `wiringVerdict()` - * reads it out of `.github/workflows/lint.yml` and the self-test - * fails when it drifts; - * * steps in a job do not overlap, so while this reporter runs, no - * other step is executing. A step the API still shows `in_progress` - * has in fact finished and its completion record has not landed yet; - * * the runner went from that step to the `if: failure()` epilogue - * instead of to the next gate, which is what failing means. - * - * ⭐ This reads `status` and position, never `conclusion`. It is the same - * move `declaredRegion()` already makes for the post block, and the same - * one the count makes behind the failure. - * - * 3. Anything else -- no evidence that the job failed at all (a green job - * has exactly this shape), or evidence that it did but no single - * unfinished step to anchor on (the failing step's START has not landed - * either, so it is indistinguishable from a step never reached). ⛔ Both - * answer NOT MEASURED with a distinct code. Zero and NOT MEASURED are - * different answers, and a guessed position would be worse than the - * honest refusal this file already made. - * - * @param {Array>} declared the declared region - * @param {string} [selfStepName] the name of the step running THIS script - * @returns {{ idx: number, via?: string, code?: string, reason?: string }} - */ -export function anchorFailure(declared, selfStepName = '') { - const list = Array.isArray(declared) ? declared : []; - const nameOf = (s) => String(s?.name ?? '(unnamed step)'); - const statusOf = (s) => String(s?.status ?? ''); - - const byConclusion = list.findIndex((s) => String(s?.conclusion ?? '') === 'failure'); - if (byConclusion !== -1) return { idx: byConclusion, via: 'conclusion' }; - - const selfRunning = - selfStepName !== '' && - list.some((s) => nameOf(s) === selfStepName && statusOf(s) === 'in_progress'); - if (!selfRunning) { - return { - idx: -1, - code: 'no_failure_recorded', - reason: - 'no step in this job reported conclusion "failure", and this reporter is not ' + - 'recorded as running either, so nothing here says the job failed at all', - }; - } - - const unfinished = list - .map((s, i) => ({ s, i })) - .filter(({ s }) => nameOf(s) !== selfStepName && statusOf(s) === 'in_progress'); - if (unfinished.length === 1) return { idx: unfinished[0].i, via: 'status' }; - - return { - idx: -1, - code: 'failure_unstamped', - reason: - 'this reporter runs only on failure() and it is running, so a step in this job failed, ' + - 'but the jobs API carries neither a "failure" conclusion nor exactly one unfinished ' + - `step to anchor on (${unfinished.length} found), so the tail has no measurable start`, - }; -} - -/** - * Count the tail, from one job's `steps[]`. - * - * Pure, so `--self-test` drives the real decision over recorded API responses - * rather than over an imitation of it. - * - * The three populations are kept apart on purpose, because collapsing them is - * the defect this reports on: - * - * ran executed, and said something - * skippedByCondition turned off by its OWN `if:` BEFORE the failure -- a - * deliberate, paid-for absence (the gate-family - * selector), not an unmeasured one - * neverRan behind the failure. Once the job is over the API calls - * these "skipped" too, which is precisely why a reader - * of that surface cannot tell them from the line above. - * - * ## Behind the failure, POSITION is the authority, not `conclusion` - * - * ⚠️ Measured, and it cost a wrong number once: this runs while the job is still - * in progress, and the runner stamps `skipped` on the steps behind the failure - * PROGRESSIVELY. In run 34583803573 of this workflow -- a deliberate - * double-failure probe -- the completed job ended with 161 skipped declared - * steps, but at the instant this step read the API only 12 of them carried that - * word. Counting `conclusion === 'skipped'` therefore reported `never_ran=12` - * for a tail of 161: a lower bound on a lower bound, and a number that reads as - * precise. - * - * So the tail is counted by POSITION -- every declared step behind the failing - * one -- and `conclusion` is used only to SUBTRACT the steps that demonstrably - * did run (an `if: always()`/`if: failure()` step, and this reporter itself, - * which is the one step in `in_progress` while this executes). A step the runner - * has not reached yet is not evidence of anything, and is not read as one. - * - * ## The ANCHOR had the same disease, and it is the field that heals last - * - * ⚠️ Measured over this instrument's whole lifetime (issue #18874; the census - * is comment 5737448141 on that card): of the 42 failing `Lint & Repo Gates` - * jobs that carried this reporter, 24 disclosed NOT MEASURED, and 4 of 4 logs - * read verbatim gave one reason -- no step carried `conclusion: "failure"` at - * the instant of the read. The rule above had been applied to the COUNT and - * written down twice, while the one `conclusion` the anchor still trusted was - * the least likely of all to be final: the step that exited non-zero a - * fraction of a second earlier. The quantity that separates a job that - * measured from one that did not is how many declared steps remain BEHIND the - * failure (Fisher exact, two-sided, p = 0.00099) -- ⛔ NOT the gap between the - * failure and this read, whose smallest value in the timing sub-sample belongs - * to a job that did not measure. So waiting longer is not the repair; not - * depending on that field is. - * - * `anchorFailure()` therefore takes the position from whichever surface can - * carry it, and REFUSES rather than picking one when neither can. It never - * widens what counts as measured on its own: see the three cases there. - * - * @param {Array>} steps a job's `steps[]` from the API - * @param {string} [selfStepName] the name of the step running THIS script, so - * that it never reports itself as a gate that never ran. Supplied by the - * workflow through `OS_TAIL_REPORT_STEP` rather than written here: the literal - * then lives beside the step it names, and if the two ever drift the step - * reappears in the tail as a visible off-by-one rather than going wrong - * quietly. Measured first, in run 34584689309: without it the report ended - * `- Report how many gates never ran`, while that step was the one printing. - * @returns {{ - * measured: boolean, reason?: string, code?: string, anchoredBy: string|null, - * total: number, failedNumber: number|null, - * failedName: string|null, ran: number, skippedByCondition: string[], - * neverRan: string[], alsoFailed: string[], inFlight: string[], - * }} - */ -export function judge(steps, selfStepName = '') { - const empty = { - measured: false, - anchoredBy: null, - total: 0, - failedNumber: null, - failedName: null, - ran: 0, - skippedByCondition: [], - neverRan: [], - alsoFailed: [], - inFlight: [], - }; - - const list = Array.isArray(steps) ? steps : []; - if (list.length === 0) { - return { ...empty, code: 'no_steps', reason: 'the jobs API returned no steps for this job' }; - } - - const { declared } = declaredRegion(list); - if (declared.length === 0) { - return { - ...empty, - code: 'no_declared_steps', - reason: 'the jobs API returned no declared steps for this job', - }; - } - - const anchor = anchorFailure(declared, selfStepName); - if (anchor.idx === -1) { - return { ...empty, total: declared.length, code: anchor.code, reason: anchor.reason }; - } - const failedIdx = anchor.idx; - - const nameOf = (s) => String(s?.name ?? '(unnamed step)'); - const conclusionOf = (s) => String(s?.conclusion ?? ''); - - const before = declared.slice(0, failedIdx); - const after = declared - .slice(failedIdx + 1) - .filter((s) => selfStepName === '' || nameOf(s) !== selfStepName); - - // The only step that can be `in_progress` behind the failure is this reporter, - // because it is the one running right now. Held apart so it is never counted - // as an unmeasured gate. - const inFlight = after.filter((s) => String(s?.status ?? '') === 'in_progress').map(nameOf); - - return { - measured: true, - anchoredBy: anchor.via, - total: declared.length, - failedNumber: Number(declared[failedIdx]?.number ?? 0) || null, - failedName: nameOf(declared[failedIdx]), - // Before the failure every conclusion is already final, so it can be read. - ran: before.filter((s) => conclusionOf(s) !== 'skipped').length, - skippedByCondition: before.filter((s) => conclusionOf(s) === 'skipped').map(nameOf), - // Behind it, none of them are. Position decides; `conclusion` only subtracts. - neverRan: after - .filter((s) => String(s?.status ?? '') !== 'in_progress' && !EXECUTED.has(conclusionOf(s))) - .map(nameOf), - alsoFailed: after.filter((s) => conclusionOf(s) === 'failure').map(nameOf), - inFlight, - }; -} - -/** - * The one machine-readable line. Stable key=value, one line, easy to grep. - * - * Both halves name WHICH of their several shapes they are, appended so that the - * `measured=no never_ran=NOT_MEASURED` and `measured=yes never_ran=N` prefixes - * every existing reader greps for are unchanged: - * - * `reason=` on a refusal, because the annotation surface otherwise collapses - * four different refusals into one string and only the job log keeps them - * apart -- so a census taken from annotations alone cannot tell the race from - * an unreadable API. It could not, when this card was measured. - * - * `anchored_by=` on a measurement, because a fix whose effect cannot be - * counted on the surface the census reads is a fix nobody can confirm. - */ -export function machineLine(v) { - if (!v.measured) { - return ( - 'unmeasured-gate-tail: measured=no never_ran=NOT_MEASURED' + - ` reason=${v.code ?? 'unclassified'}` - ); - } - return ( - 'unmeasured-gate-tail: measured=yes' + - ` never_ran=${v.neverRan.length}` + - ` failed=${1 + v.alsoFailed.length}` + - ` ran=${v.ran}` + - ` skipped_by_condition=${v.skippedByCondition.length}` + - ` declared=${v.total}` + - ` failed_at_step=${v.failedNumber ?? 'unknown'}` + - ` anchored_by=${v.anchoredBy ?? 'conclusion'}` - ); -} - -/** - * The human half, for the job log and the job summary. - * - * The un-run gates are listed in FULL and never truncated: a report about an - * unmeasured tail that itself ends in "... and 94 more" has reproduced the - * defect one level up. - */ -export function renderReport(v) { - const out = []; - out.push(''); - out.push('--- UNMEASURED GATE TAIL ------------------------------------------'); - out.push(''); - out.push('This job stops at the first non-zero exit, deliberately (see the GATE'); - out.push('ORDER note in .github/workflows/lint.yml). So the red above is a LOWER'); - out.push('BOUND on the number of problems in this tree, not a count.'); - out.push(''); - - if (!v.measured) { - out.push(` NOT MEASURED: ${v.reason}.`); - out.push(''); - out.push(' A tail of unknown size is still a tail. Do not read the gates behind'); - out.push(' the failure as passing -- nothing here says they ran.'); - out.push(''); - out.push(` ${machineLine(v)}`); - out.push(''); - return out.join('\n'); - } - - out.push(` failed at step #${v.failedNumber}: ${v.failedName}`); - if (v.anchoredBy === 'status') { - out.push(' (that step was not stamped "failure" yet when this read the API. It'); - out.push(' is the one step the API still showed unfinished, and steps in a job'); - out.push(' do not overlap, so it is where this job stopped -- found by POSITION,'); - out.push(' not by its conclusion. The tail below is unaffected either way.)'); - } - out.push(` declared steps in this job: ${v.total}`); - out.push(` ran before the failure: ${v.ran}`); - out.push(` turned off by their own condition: ${v.skippedByCondition.length}`); - out.push(` NEVER RAN (the unmeasured tail): ${v.neverRan.length}`); - if (v.alsoFailed.length > 0) { - out.push(` further steps that also FAILED: ${v.alsoFailed.length}`); - } - out.push(''); - - if (v.neverRan.length === 0) { - out.push(' The tail is empty: every other gate in this job reported. For THIS'); - out.push(' run the red count above is a count, not a lower bound.'); - } else { - out.push(` These ${v.neverRan.length} gate(s) produced no output because they never executed.`); - out.push(' That is NOT MEASURED, not "passed". Fix the failure above and re-run'); - out.push(' to find out what they would have said:'); - out.push(''); - for (const name of v.neverRan) out.push(` - ${name}`); - } - - if (v.skippedByCondition.length > 0) { - out.push(''); - out.push(' Turned off by their own condition (a deliberate absence, not this'); - out.push(' tail) -- the jobs API reports these with the same "skipped" word:'); - for (const name of v.skippedByCondition) out.push(` - ${name}`); - } - - out.push(''); - out.push(` ${machineLine(v)}`); - out.push(''); - return out.join('\n'); -} - -/** The same verdict as GitHub-flavoured markdown, for the job summary. */ -export function renderSummary(v) { - const lines = ['## Unmeasured gate tail', '']; - if (!v.measured) { - lines.push(`**NOT MEASURED** — ${v.reason}.`, '', '```', machineLine(v), '```', ''); - return lines.join('\n'); - } - lines.push( - `This job aborts at the first non-zero exit, so the red is a **lower bound**, not a count.`, - '', - `| | |`, - `|:--|--:|`, - `| failed at step #${v.failedNumber} | \`${v.failedName}\` |`, - `| ran before the failure | ${v.ran} |`, - `| turned off by their own condition | ${v.skippedByCondition.length} |`, - `| **never ran (unmeasured tail)** | **${v.neverRan.length}** |`, - '', - ); - if (v.anchoredBy === 'status') { - lines.push( - 'The failing step was not stamped `failure` yet at the instant of this read; it was' + - ' located by position (the one step the API still showed unfinished) rather than by' + - ' its conclusion.', - '', - ); - } - if (v.neverRan.length > 0) { - lines.push('
The gates that never ran', ''); - for (const name of v.neverRan) lines.push(`- ${name}`); - lines.push('', '
', ''); - } - lines.push('```', machineLine(v), '```', ''); - return lines.join('\n'); -} - -// -- the live half ---------------------------------------------------------- - -/** - * Fetch every job of this run, following pagination. - * - * @param {{ repository: string, runId: string, token: string, apiUrl: string, fetchImpl?: typeof fetch }} opts - */ -export async function fetchRunJobs({ repository, runId, token, apiUrl, fetchImpl }) { - const doFetch = fetchImpl ?? fetch; - const jobs = []; - for (let page = 1; page <= 10; page++) { - const url = `${apiUrl}/repos/${repository}/actions/runs/${runId}/jobs?per_page=100&page=${page}`; - const res = await doFetch(url, { - headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${token}`, - 'x-github-api-version': '2022-11-28', - }, - }); - if (!res.ok) throw new Error(`GET ${url} answered ${res.status}`); - const body = await res.json(); - const batch = Array.isArray(body?.jobs) ? body.jobs : []; - jobs.push(...batch); - if (batch.length < 100) break; - } - return jobs; -} - -/** - * Pick THIS job out of the run's jobs. - * - * Matched on `runner_name`, which the runner also exports as `RUNNER_NAME`, so - * nothing here hardcodes the job's `name:` -- that literal is the required - * status-check context (lint.yml's own note), and a reporter that silently - * stopped matching when it was renamed would be the same class of defect this - * file exists to remove. The in-progress filter breaks the tie if a runner name - * is ever reused inside one run. - * - * @param {Array>} jobs - * @param {string} runnerName - */ -export function pickOwnJob(jobs, runnerName) { - const list = Array.isArray(jobs) ? jobs : []; - const byRunner = list.filter((j) => String(j?.runner_name ?? '') === runnerName && runnerName !== ''); - if (byRunner.length === 1) return byRunner[0]; - const running = (byRunner.length > 1 ? byRunner : list).filter( - (j) => String(j?.status ?? '') === 'in_progress', - ); - if (running.length === 1) return running[0]; - return null; -} - -async function main() { - const env = process.env; - const token = env.GITHUB_TOKEN ?? ''; - const repository = env.GITHUB_REPOSITORY ?? ''; - const runId = env.GITHUB_RUN_ID ?? ''; - const apiUrl = env.GITHUB_API_URL ?? 'https://api.github.com'; - const runnerName = env.RUNNER_NAME ?? ''; - - const missing = [ - ['GITHUB_TOKEN', token], - ['GITHUB_REPOSITORY', repository], - ['GITHUB_RUN_ID', runId], - ] - .filter(([, value]) => value === '') - .map(([key]) => key); - - /** @type {ReturnType} */ - let verdict; - if (missing.length > 0) { - verdict = judge([]); - verdict.code = 'env_missing'; - verdict.reason = `${missing.join(', ')} not set, so this run's steps could not be read`; - } else { - try { - const jobs = await fetchRunJobs({ repository, runId, token, apiUrl }); - const own = pickOwnJob(jobs, runnerName); - if (own === null) { - verdict = judge([]); - verdict.code = 'job_unidentified'; - verdict.reason = - `none of the ${jobs.length} job(s) in run ${runId} could be identified as this one ` + - `(runner ${runnerName || 'unnamed'})`; - } else { - verdict = judge(own.steps, env.OS_TAIL_REPORT_STEP ?? ''); - } - } catch (err) { - verdict = judge([]); - verdict.code = 'api_error'; - verdict.reason = `the jobs API could not be read: ${err instanceof Error ? err.message : String(err)}`; - } - } - - console.log(renderReport(verdict)); - - // The annotation surface. `notice` on purpose: a check-run that is already - // failed keeps its conclusion, and a notice can be read neither as a pass nor - // as a downgrade of the red. - console.log(`::notice title=Unmeasured gate tail::${machineLine(verdict)}`); - - const summaryPath = env.GITHUB_STEP_SUMMARY; - if (summaryPath) { - try { - appendFileSync(summaryPath, `${renderSummary(verdict)}\n`, 'utf8'); - } catch (err) { - console.log(` (job summary not written: ${err instanceof Error ? err.message : String(err)})`); - } - } - - // Always 0. This step runs only when the job has ALREADY failed; a non-zero - // exit here would add a second failure annotation that points at the reporter - // instead of at the gate, and would say nothing the job's conclusion does not - // already say. - process.exit(0); -} - -// -- self-test -------------------------------------------------------------- - -/** - * `OS_TAIL_REPORT_STEP` must be the literal `name:` of the step that sets it, - * and that step must declare `if: failure()`. - * - * Read as plain text on purpose: the assertion is about two LINES of the - * workflow agreeing, it needs no schema, and a text read works before - * `pnpm install` has put a YAML parser on disk -- which matters, because the - * step this file backs is the one that reports on a job that may have died at - * the install step. - * - * ⭐ The condition half is not decoration. `anchorFailure()` case 2 reads this - * reporter's own `in_progress` record as the runner's statement that a step in - * this job failed, and that inference is licensed by `if: failure()` and by - * nothing else. Loosen the condition and the positional anchor would start - * answering on jobs that never failed -- so the loosening fails HERE, in a - * gate that runs on every red job, rather than silently in production. - * - * @param {string} source the text of `.github/workflows/lint.yml` - * @returns {{ ok: boolean, note: string }} - */ -export function wiringVerdict(source) { - const lines = String(source).split('\n'); - const found = []; - for (let i = 0; i < lines.length; i++) { - const env = /^\s*OS_TAIL_REPORT_STEP:\s*(.+?)\s*$/.exec(lines[i]); - if (env === null) continue; - let declared = null; - let condition = null; - for (let j = i - 1; j >= 0 && j > i - 40; j--) { - const when = /^\s*if:\s*(.+?)\s*$/.exec(lines[j]); - if (when !== null && condition === null) condition = when[1]; - const name = /^\s*- name:\s*(.+?)\s*$/.exec(lines[j]); - if (name !== null) { - declared = name[1].replace(/^['"]|['"]$/g, ''); - break; - } - } - found.push({ env: env[1].replace(/^['"]|['"]$/g, ''), declared, condition }); - } - if (found.length === 0) { - return { ok: false, note: 'no step sets OS_TAIL_REPORT_STEP, so this reporter would list itself' }; - } - const wrong = found.filter((f) => f.declared !== f.env); - if (wrong.length > 0) { - return { - ok: false, - note: `OS_TAIL_REPORT_STEP does not match its own step name: ${JSON.stringify(wrong)}`, - }; - } - const unconditioned = found.filter((f) => !/failure\(\)/.test(String(f.condition ?? ''))); - if (unconditioned.length > 0) { - return { - ok: false, - note: - 'the step that sets OS_TAIL_REPORT_STEP no longer declares `if: failure()`, so this ' + - 'reporter running is no longer evidence that the job failed and the positional ' + - `anchor would answer on green jobs: ${JSON.stringify(unconditioned)}`, - }; - } - return { - ok: true, - note: `${found.length} step(s) set OS_TAIL_REPORT_STEP to their own name, under \`if: failure()\``, - }; -} - -/** This repo's root, resolved from this file rather than from the cwd. */ -function repoRoot() { - return resolve(dirname(fileURLToPath(import.meta.url)), '..'); -} - -/** - * Recorded `steps[]` shapes, abbreviated from real jobs of this repo. - * - * `lintFailedEarly` is job 102842713265 of run 34468505490 reduced to its - * shape: `Set up job`, then declared steps, one of which failed, then skipped - * steps, then the reserved post block with its numbering gap. - */ -function fixtures() { - const step = (number, name, conclusion, status = 'completed') => ({ number, name, conclusion, status }); - const lintFailedEarly = [ - step(1, INJECTED_HEAD, 'success'), - step(2, 'Checkout repository', 'success'), - step(3, 'Install dependencies', 'success'), - step(4, 'Slot-lookup ratchet', 'skipped'), - step(5, 'Docs anchors resolve to real headings', 'success'), - step(6, 'Doc/skill authoring guard', 'failure'), - step(7, 'Docs frontmatter parses', 'skipped'), - step(8, 'One `

` per docs page', 'skipped'), - step(9, 'Report the unmeasured gate tail', null, 'in_progress'), - step(332, 'Post Setup pnpm cache', 'skipped'), - step(333, 'Post Checkout repository', 'success'), - step(334, 'Complete job', 'success'), - ]; - const lintFailedLast = [ - step(1, INJECTED_HEAD, 'success'), - step(2, 'Checkout repository', 'success'), - step(3, 'ESLint', 'success'), - step(4, 'Duration-shaped spec keys carry their unit in the key name', 'failure'), - step(5, 'Report the unmeasured gate tail', null, 'in_progress'), - step(336, 'Post Checkout repository', 'success'), - step(337, 'Complete job', 'success'), - ]; - const green = [ - step(1, INJECTED_HEAD, 'success'), - step(2, 'Checkout repository', 'success'), - step(3, 'ESLint', 'success'), - step(336, 'Post Checkout repository', 'success'), - step(337, 'Complete job', 'success'), - ]; - // The shape this actually runs against, and the one that produced a wrong - // number before the rule above existed: the job is still in progress, so the - // runner has stamped `skipped` on only the first slice of the tail and has - // written nothing at all for the rest. - const midRun = [ - step(1, INJECTED_HEAD, 'success'), - step(2, 'Checkout repository', 'success'), - step(3, 'Docs anchors resolve to real headings', 'failure'), - step(4, 'ESLint', 'skipped'), - step(5, 'Raw control-byte guard', 'skipped'), - step(6, 'Slot-lookup ratchet', null, 'queued'), - step(7, 'ADR anchors + number uniqueness', null, 'queued'), - step(8, 'Duration-shaped spec keys carry their unit in the key name', null, 'queued'), - step(9, 'Report how many gates never ran', null, 'in_progress'), - step(340, 'Post Checkout repository', 'success'), - step(341, 'Complete job', 'success'), - ]; - // ⭐ The shape the recorded fixtures above could NOT express, and the one - // this reporter meets on more than half of the jobs it runs on: at the - // instant of the read the failing step has no final conclusion yet. The - // runner has already moved on to the `if: failure()` epilogue -- the - // self-test step below ran and passed -- but the jobs API still answers with - // the last state that step was recorded in, `in_progress`, and has written - // nothing at all for the gates behind it. Every fixture above hands the - // anchor a stamped `failure`, which is why 32 green assertions were - // compatible with a reporter that disclosed NOT MEASURED in production. - const failureNotYetStamped = [ - step(1, INJECTED_HEAD, 'success'), - step(2, 'Checkout repository', 'success'), - step(3, 'Install dependencies', 'success'), - step(4, 'Slot-lookup ratchet', 'skipped'), - step(5, 'Docs anchors resolve to real headings', 'success'), - step(6, 'Doc/skill authoring guard', null, 'in_progress'), - step(7, 'Docs frontmatter parses', null, 'queued'), - step(8, 'One `

` per docs page', null, 'queued'), - step(9, 'Unmeasured-gate-tail reporter self-test', 'success'), - step(10, 'Report how many gates never ran', null, 'in_progress'), - step(340, 'Post Checkout repository', 'success'), - step(341, 'Complete job', 'success'), - ]; - // One propagation step earlier still: the failing step's START has not landed - // either, so from this surface it is indistinguishable from a gate the runner - // never reached. There is a position here, but no evidence for it. - const failureNotYetStarted = failureNotYetStamped.map((x) => - x.name === 'Doc/skill authoring guard' ? { ...x, status: 'queued' } : x); - return { lintFailedEarly, lintFailedLast, green, midRun, failureNotYetStamped, failureNotYetStarted }; -} - -function selfTest() { - const failures = []; - let checked = 0; - const t = (label, actual, expected) => { - checked++; - const a = JSON.stringify(actual); - const e = JSON.stringify(expected); - if (a !== e) failures.push(`${label}: expected ${e}, got ${a}`); - }; - - const { lintFailedEarly, lintFailedLast, green, midRun, failureNotYetStamped, - failureNotYetStarted } = fixtures(); - /** The literal `.github/workflows/lint.yml` passes through OS_TAIL_REPORT_STEP. */ - const REPORTER = 'Report how many gates never ran'; - - // -- the boundary: the post block is not part of the population ------------ - t('declaredRegion drops the runner head and the whole post block', - declaredRegion(lintFailedEarly).declared.map((s) => s.name), - ['Checkout repository', 'Install dependencies', 'Slot-lookup ratchet', - 'Docs anchors resolve to real headings', 'Doc/skill authoring guard', - 'Docs frontmatter parses', 'One `

` per docs page', - 'Report the unmeasured gate tail']); - t('the numbering gap before the post block is NOT read as truncation', - declaredRegion(lintFailedEarly).postIndex, 9); - - // -- the three populations stay apart -------------------------------------- - const early = judge(lintFailedEarly); - t('an early failure is measured', early.measured, true); - t('...names the failing step', [early.failedNumber, early.failedName], [6, 'Doc/skill authoring guard']); - t('...counts the tail behind the failure', early.neverRan, - ['Docs frontmatter parses', 'One `

` per docs page']); - t('...keeps an `if:`-skipped step OUT of the tail', early.skippedByCondition, ['Slot-lookup ratchet']); - t('...counts what actually ran', early.ran, 3); - t('...never counts this reporter itself as an unmeasured gate', early.inFlight, - ['Report the unmeasured gate tail']); - - // The point of the whole exercise: the two "skipped" populations are the same - // word on the API surface and MUST NOT be the same number here. - t('a deliberate skip and an unmeasured one are different numbers', - [early.skippedByCondition.length, early.neverRan.length], [1, 2]); - - // -- mid-run: a not-yet-stamped step is part of the tail, not evidence ------ - // The regression this pins: counting `conclusion === 'skipped'` answered 12 - // on a live job whose real tail was 161, because the runner had not reached - // the rest yet. Position decides; `conclusion` may only subtract. - const mid = judge(midRun); - t('a step the runner has not reached yet is counted in the tail', - mid.neverRan, - ['ESLint', 'Raw control-byte guard', 'Slot-lookup ratchet', - 'ADR anchors + number uniqueness', - 'Duration-shaped spec keys carry their unit in the key name']); - t('...so the tail is the POSITION count, not the stamped-skipped count', - [mid.neverRan.length, midRun.filter((x) => x.conclusion === 'skipped').length], [5, 2]); - t('...and this reporter, the one step in flight, is never in it', - [mid.inFlight, mid.neverRan.includes('Report how many gates never ran')], - [['Report how many gates never ran'], false]); - - // The reporter is not always stamped `in_progress` by the time it reads the - // API -- measured in run 34584689309, where it listed ITSELF in its own tail. - // The `in_progress` rule alone does not catch that; the name does. - const selfNotYetMarked = midRun.map((x) => - x.name === 'Report how many gates never ran' ? { ...x, status: 'queued' } : x); - t('an unmarked reporter WOULD list itself, which is why the workflow passes a name', - judge(selfNotYetMarked).neverRan.includes('Report how many gates never ran'), true); - t('...and naming it removes exactly that one entry, nothing else', - [judge(selfNotYetMarked, 'Report how many gates never ran').neverRan.includes('Report how many gates never ran'), - judge(selfNotYetMarked).neverRan.length - - judge(selfNotYetMarked, 'Report how many gates never ran').neverRan.length], - [false, 1]); - t('a name that matches nothing changes no count', - judge(selfNotYetMarked, 'Report how many gates NEVER ran').neverRan.length, - judge(selfNotYetMarked).neverRan.length); - - // -- an empty tail is a real, different answer ------------------------------ - const last = judge(lintFailedLast); - t('a failure in the last gate reports an EMPTY tail rather than staying silent', - [last.measured, last.neverRan.length], [true, 0]); - t('...and says so in words', renderReport(last).includes('The tail is empty'), true); - - // -- the anchor: at the instant of the read the failure has no conclusion --- - // The defect this battery could not previously express (#18874). Every - // fixture above hands `judge()` a failing step already stamped `failure`; - // this one is the live shape, where it is not. The card's mechanism was read - // verbatim out of 4 of 4 production logs, and the census of all 42 jobs the - // instrument ever ran on is comment 5737448141. - t('the race fixture carries NO stamped failure at all -- which is why the old anchor refused it', - failureNotYetStamped.some((x) => x.conclusion === 'failure'), false); - const racing = judge(failureNotYetStamped, REPORTER); - t('a failing step the API has not stamped yet is still found', racing.measured, true); - t('...by position, and the verdict says so rather than implying a final conclusion', - [racing.anchoredBy, racing.failedNumber, racing.failedName], - ['status', 6, 'Doc/skill authoring guard']); - t('...and the tail is the unreached gates ONLY: the `if: failure()` epilogue ran, so it is not in it', - racing.neverRan, ['Docs frontmatter parses', 'One `

` per docs page']); - t('...while everything before the failure is read exactly as before', - [racing.ran, racing.skippedByCondition], [3, ['Slot-lookup ratchet']]); - t('...and the machine line carries the anchor it used, for a census to count', - machineLine(racing), - 'unmeasured-gate-tail: measured=yes never_ran=2 failed=1 ran=3 skipped_by_condition=1 ' + - 'declared=9 failed_at_step=6 anchored_by=status'); - - // ⛔ The positional anchor is licensed by this reporter's own `if: failure()` - // and by nothing else. Unnamed, it must NOT fire -- otherwise the repair - // would invent a failure on a job that never had one. - t('without the reporter named, the position is not licensed and the answer stays NOT MEASURED', - [judge(failureNotYetStamped).measured, judge(failureNotYetStamped).code], - [false, 'no_failure_recorded']); - - // ⛔ And it refuses rather than guessing when the position is not evidenced. - const notStarted = judge(failureNotYetStarted, REPORTER); - t('a failing step whose START has not landed either is NOT MEASURED, never a guessed position', - [notStarted.measured, notStarted.code, notStarted.neverRan.length], - [false, 'failure_unstamped', 0]); - t('...and its reason says a failure DID happen, so the number is missing rather than zero', - notStarted.reason.includes('a step in this job failed'), true); - const twoUnfinished = failureNotYetStamped.map((x) => - x.name === 'Docs anchors resolve to real headings' - ? { ...x, conclusion: null, status: 'in_progress' } : x); - t('two unfinished steps are an ambiguity, and an ambiguity is NOT MEASURED, not a pick', - [judge(twoUnfinished, REPORTER).measured, judge(twoUnfinished, REPORTER).code], - [false, 'failure_unstamped']); - t('a stamped failure still wins over any position, so nothing above changes the settled case', - [judge(lintFailedEarly, REPORTER).anchoredBy, judge(midRun).anchoredBy], - ['conclusion', 'conclusion']); - - // -- NOT MEASURED is never rendered as zero -------------------------------- - const noFailure = judge(green); - t('a job with no failed step is NOT MEASURED, not a tail of 0', noFailure.measured, false); - t('...and its machine line says NOT_MEASURED rather than 0', - machineLine(noFailure), - 'unmeasured-gate-tail: measured=no never_ran=NOT_MEASURED reason=no_failure_recorded'); - t('...for the reason that nothing says it failed, not for the race reason', - noFailure.code, 'no_failure_recorded'); - t('an empty steps array is NOT MEASURED too', judge([]).measured, false); - t('a null steps value is NOT MEASURED too', judge(null).measured, false); - t('...and each refusal names which shape it is, rather than collapsing into one string', - [judge([]).code, judge([{ name: 'Post x', conclusion: 'success' }]).code], - ['no_steps', 'no_declared_steps']); - - // -- the machine-readable line --------------------------------------------- - t('the machine line carries every count', - machineLine(early), - 'unmeasured-gate-tail: measured=yes never_ran=2 failed=1 ran=3 skipped_by_condition=1 ' + - 'declared=8 failed_at_step=6 anchored_by=conclusion'); - - // -- the report never truncates the list it is about ------------------------ - const manyNames = Array.from({ length: 120 }, (_, i) => `gate ${i}`); - const manyRendered = renderReport({ ...early, neverRan: manyNames }); - t('every un-run gate is named, with nothing elided', - manyNames.every((n) => manyRendered.includes(` - ${n}`)), true); - - // -- output must not mint workflow commands of its own --------------------- - // `check:self-test-workflow-commands` reads this file's self-test output; a - // legacy `##[` token is parsed ANYWHERE in a line and a `::` form at line - // start, so neither may appear in anything rendered here. - // The two tokens are BUILT rather than written, so that a failing assertion - // here cannot print the very thing it refuses. - const legacyToken = `${'#'.repeat(2)}[`; - const modernToken = ':'.repeat(2); - const renderedLines = [ - renderReport(early), - renderReport(noFailure), - renderSummary(early), - renderSummary(noFailure), - ] - .join('\n') - .split('\n'); - t('no rendered line carries the legacy command token, which parses anywhere in a line', - renderedLines.some((l) => l.includes(legacyToken)), false); - t('no rendered line starts with the modern command token, which parses at line start', - renderedLines.some((l) => l.startsWith(modernToken)), false); - - // -- the workflow wiring, read from the real file --------------------------- - const workflow = readFileSync(join(repoRoot(), '.github/workflows/lint.yml'), 'utf8'); - const wiring = wiringVerdict(workflow); - t(`lint.yml wires OS_TAIL_REPORT_STEP to its own step name (${wiring.note})`, wiring.ok, true); - t('a renamed step with a stale env value is caught', - wiringVerdict(' - name: Renamed\n env:\n OS_TAIL_REPORT_STEP: Old name\n').ok, - false); - t('a missing wiring is caught too, not read as clean', - wiringVerdict(' - name: Report how many gates never ran\n run: node x.mjs\n').ok, false); - t('a quoted step name matches its unquoted env value', - wiringVerdict( - " - name: 'A name'\n if: failure()\n env:\n OS_TAIL_REPORT_STEP: A name\n", - ).ok, true); - // ⛔ The condition is what licenses `anchorFailure()` case 2. Dropped or - // widened, the positional anchor would start answering on jobs that never - // failed -- so it fails here instead. - t('a reporter step that no longer declares `if: failure()` is caught', - wiringVerdict( - ' - name: A name\n env:\n OS_TAIL_REPORT_STEP: A name\n', - ).ok, false); - t('...and so is one whose condition was widened to always()', - wiringVerdict( - ' - name: A name\n if: always()\n env:\n OS_TAIL_REPORT_STEP: A name\n', - ).ok, false); - - // -- picking this job out of the run --------------------------------------- - const jobs = [ - { name: 'Type Check', runner_name: 'GitHub Actions 1', status: 'completed', steps: [] }, - { name: 'Lint & Repo Gates', runner_name: 'GitHub Actions 2', status: 'in_progress', steps: lintFailedEarly }, - ]; - t('this job is found by runner name, with no job `name:` literal anywhere', - pickOwnJob(jobs, 'GitHub Actions 2')?.name, 'Lint & Repo Gates'); - t('an unknown runner falls back to the single in-progress job', - pickOwnJob(jobs, 'GitHub Actions 999')?.name, 'Lint & Repo Gates'); - t('an unidentifiable job is null, never a guess', - pickOwnJob([jobs[0]], 'GitHub Actions 999'), null); - - if (failures.length) { - console.error(`x report-unmeasured-gate-tail --self-test -- ${failures.length} failure(s)\n`); - for (const f of failures) console.error(` - ${f}`); - process.exit(1); - } - console.log( - `+ report-unmeasured-gate-tail --self-test: ${checked} assertions over recorded jobs-API shapes ` + - '(real judge()/renderReport() path; the two "skipped" populations, the empty tail, ' + - 'NOT MEASURED vs zero, the no-truncation rule, and the live shape in which the ' + - 'failing step carries no final conclusion yet)', - ); - return SELF_TEST_VERDICT; -} - -// Exports bindings, so an import for those exports alone must run nothing (#10667). -const invokedDirectly = isEntrypoint(import.meta.url); - -if (!invokedDirectly) { - // imported as a module -- expose the exports and do nothing else -} else if (process.argv.includes('--self-test')) { - if (selfTest() !== SELF_TEST_VERDICT) { - console.error( - '\nx report-unmeasured-gate-tail self-test: selfTest() returned without reaching its\n' + - 'verdict, so no success line was printed. Exiting 0 here would report a self-test\n' + - 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } -} else { - await main(); -} From 5a7b95e15227833297d308f4b26145c6dabd0692 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 08:54:30 +0000 Subject: [PATCH 3/6] ci(gates): rewrite the not-continue-on-error note where it stood Claude-Session: https://claude.ai/code/session_012zwsLMxQBwHUYJgPyrpEvx Co-authored-by: Claude --- .github/workflows/lint.yml | 12 ++++++++++++ scripts/ci/lane-verdict.mjs | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 66992f0b198..d37bbe683c3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -513,6 +513,18 @@ jobs: # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + # + # This is where the unmeasured-tail reporter pair stood, and its note + # said the fix for the gates a red skipped was not to run them, and ⛔ not + # `continue-on-error`, "which trades a red for silence". What holds now: + # the skipped gates DO run — every gate step above carries + # `continue-on-error` — and the trade is refused HERE: this step reads + # every gate's `outcome`, names the red ones and exits 1, so the job's + # conclusion is still red, never silent. The tail behind a red gate is + # empty by construction; behind a failed setup step this step lists the + # gates that never ran, from the `steps` context — final for every + # earlier step when this runs — rather than from the jobs API the + # reporter had to race (#18874), so no `actions: read` is needed any more. - name: Lane verdict if: ${{ !cancelled() }} env: diff --git a/scripts/ci/lane-verdict.mjs b/scripts/ci/lane-verdict.mjs index 9a0c3f34227..20bc99df70c 100644 --- a/scripts/ci/lane-verdict.mjs +++ b/scripts/ci/lane-verdict.mjs @@ -76,8 +76,9 @@ // * It changes no gate's command and judges nothing about the tree. A gate's // own output is in its own step's log; this prints only which ones failed. // * It does not call the jobs API. The `steps` context is final for every -// step before this one when this runs, which the API is not (the reporter -// this replaces disclosed NOT MEASURED on 24 of 42 red jobs for that reason). +// step before this one when this runs, which the API is not: the +// unmeasured-tail reporter this replaces had to race the API's own +// bookkeeping for the same facts (#18874). // * It writes the job summary only when the verdict is not green, so a green // lane's summary page stays as it was. From 5301220aae728c1ca6893d8596ee690c63f206ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 08:57:10 +0000 Subject: [PATCH 4/6] proof(a1): THROWAWAY deliberately red docs anchor and Types test, reverted in the next commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dead docs anchor reddens 'Docs anchors resolve to real headings', the first gate of Lint · repo & tooling gates; a red test in @objectstack/types reddens one Test Core shard. Pushed only to read the jobs API: every later gate of that lane must execute, the lane verdict must name the red gate, and turbo must carry on past the red package. Reverted before review. Claude-Session: https://claude.ai/code/session_012zwsLMxQBwHUYJgPyrpEvx Co-authored-by: Claude --- content/docs/deployment/backup-restore.mdx | 2 ++ packages/types/src/a1-proof-throwaway.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+) create mode 100644 packages/types/src/a1-proof-throwaway.test.ts diff --git a/content/docs/deployment/backup-restore.mdx b/content/docs/deployment/backup-restore.mdx index 2914ade0a69..4bc6b431a91 100644 --- a/content/docs/deployment/backup-restore.mdx +++ b/content/docs/deployment/backup-restore.mdx @@ -168,3 +168,5 @@ hot. See [Production Readiness](/docs/deployment/production-readiness). - [Production Readiness](/docs/deployment/production-readiness) - [Environment Variables](/docs/deployment/environment-variables) - [Drivers](/docs/data-modeling/drivers) + +Throwaway proof line for the A1 continue-on-error verdict (reverted before review): [a dead anchor](/docs/deployment/cli#no-such-heading-a1-proof). diff --git a/packages/types/src/a1-proof-throwaway.test.ts b/packages/types/src/a1-proof-throwaway.test.ts new file mode 100644 index 00000000000..1fdda6dcdfb --- /dev/null +++ b/packages/types/src/a1-proof-throwaway.test.ts @@ -0,0 +1,9 @@ +// THROWAWAY (#23191 proof, reverted before review): a deliberately red test so +// Test Core's --continue=dependencies-successful can be observed on CI. +import { describe, expect, it } from 'vitest'; + +describe('A1 proof (throwaway)', () => { + it('is deliberately red', () => { + expect(1).toBe(2); + }); +}); From 162d5cd77d2378cfc515256c943e59cb1395e998 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:29:28 +0000 Subject: [PATCH 5/6] proof(a1): THROWAUAY second probe, a gate its own if: turns off and a fail-fast step that fails, reverted before review lint-eslint gains an id'd continue-on-error gate whose if: is false, so the verdict must read it as skipped in the steps context and turned off by its own condition; lint-scoped-erasure gains a failing step without continue-on-error before its gates, so the verdict must report the fail-fast failure and the two gates it hid. Reverted before review. Claude-Session: https://claude.ai/code/session_012zwsLMxQBwHUYJgPyrpEvx Co-authored-by: Claude --- .github/workflows/lint.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d37bbe683c3..fca0df004e6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -511,6 +511,13 @@ jobs: continue-on-error: true run: pnpm check:engine-double-contract + # THROWAWAY (#23191 proof, reverted before review): a gate its own if: turns off. + - name: THROWAWAY proof gate turned off by its own if + id: throwaway-proof-own-if + if: ${{ false }} + continue-on-error: true + run: exit 1 + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── # @@ -4357,6 +4364,10 @@ jobs: run: | bash scripts/ci/select-gate-families.sh + # THROWAWAY (#23191 proof, reverted before review): a fail-fast step that fails. + - name: THROWAWAY proof fail-fast step + run: exit 1 + # Engine query-options erasure ratchet (#4918). The slot-lookup rule # (`lint-scoped-slot-corpus` since #22991) protects the service LOOKUP; this one protects what you pass to the # service you looked up. `IDataEngine.find/findOne/count/aggregate` declare From 476742458db1112c8556af885fef858c88faf3ff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:33:41 +0000 Subject: [PATCH 6/6] revert(a1): drop both throwaway proof commits The head's tree is byte-identical to 5a7b95e15 again; the proof readings live in the dev report on the card. Claude-Session: https://claude.ai/code/session_012zwsLMxQBwHUYJgPyrpEvx Co-authored-by: Claude --- .github/workflows/lint.yml | 11 ----------- content/docs/deployment/backup-restore.mdx | 2 -- packages/types/src/a1-proof-throwaway.test.ts | 9 --------- 3 files changed, 22 deletions(-) delete mode 100644 packages/types/src/a1-proof-throwaway.test.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fca0df004e6..d37bbe683c3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -511,13 +511,6 @@ jobs: continue-on-error: true run: pnpm check:engine-double-contract - # THROWAWAY (#23191 proof, reverted before review): a gate its own if: turns off. - - name: THROWAWAY proof gate turned off by its own if - id: throwaway-proof-own-if - if: ${{ false }} - continue-on-error: true - run: exit 1 - # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── # @@ -4364,10 +4357,6 @@ jobs: run: | bash scripts/ci/select-gate-families.sh - # THROWAWAY (#23191 proof, reverted before review): a fail-fast step that fails. - - name: THROWAWAY proof fail-fast step - run: exit 1 - # Engine query-options erasure ratchet (#4918). The slot-lookup rule # (`lint-scoped-slot-corpus` since #22991) protects the service LOOKUP; this one protects what you pass to the # service you looked up. `IDataEngine.find/findOne/count/aggregate` declare diff --git a/content/docs/deployment/backup-restore.mdx b/content/docs/deployment/backup-restore.mdx index 4bc6b431a91..2914ade0a69 100644 --- a/content/docs/deployment/backup-restore.mdx +++ b/content/docs/deployment/backup-restore.mdx @@ -168,5 +168,3 @@ hot. See [Production Readiness](/docs/deployment/production-readiness). - [Production Readiness](/docs/deployment/production-readiness) - [Environment Variables](/docs/deployment/environment-variables) - [Drivers](/docs/data-modeling/drivers) - -Throwaway proof line for the A1 continue-on-error verdict (reverted before review): [a dead anchor](/docs/deployment/cli#no-such-heading-a1-proof). diff --git a/packages/types/src/a1-proof-throwaway.test.ts b/packages/types/src/a1-proof-throwaway.test.ts deleted file mode 100644 index 1fdda6dcdfb..00000000000 --- a/packages/types/src/a1-proof-throwaway.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -// THROWAWAY (#23191 proof, reverted before review): a deliberately red test so -// Test Core's --continue=dependencies-successful can be observed on CI. -import { describe, expect, it } from 'vitest'; - -describe('A1 proof (throwaway)', () => { - it('is deliberately red', () => { - expect(1).toBe(2); - }); -});