clearotron 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +52 -0
- package/INSTALL.md +4 -3
- package/README.md +2 -1
- package/bin/example.mjs +88 -27
- package/bin/onboard.mjs +40 -2
- package/bin/start.mjs +7 -0
- package/build-info.json +2 -2
- package/docs/RELEASES.md +6 -4
- package/docs/architecture/04-configuration-reference.md +1 -1
- package/driver/CHANGELOG.md +30 -0
- package/driver/ask-ledger.mjs +69 -1
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +41 -2
- package/driver/predelivery-lint.mjs +1 -1
- package/driver/publish/seed-pool.mjs +24 -9
- package/driver/record-carry.mjs +139 -0
- package/driver/reference-score.mjs +53 -3
- package/driver/reference-strip-signatures.mjs +68 -0
- package/driver/register-digest-record.mjs +31 -1
- package/driver/repairs.mjs +1 -1
- package/driver/suite-census.json +46 -16
- package/driver/verify.mjs +2 -2
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/whatif.mjs +10 -1
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ai-page-render-check.mjs +2 -1
- package/scripts/clearances-render-check.mjs +2 -1
- package/scripts/env-audit.mjs +20 -0
- package/scripts/headless-page.mjs +225 -0
- package/scripts/home-render-check.mjs +2 -1
- package/scripts/mint-reference-strip-backlog.mjs +41 -0
- package/scripts/release-await-cut.mjs +95 -7
- package/scripts/release-version-pr-checks.mjs +25 -1
- package/scripts/report-frame-check.mjs +12 -0
- package/scripts/report-screenshot.mjs +62 -2
- package/scripts/revisit-render-check.mjs +3 -2
- package/scripts/score.mjs +14 -0
- package/shared/access-audience.mjs +215 -0
- package/shared/tracked-files.mjs +31 -0
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
// per assistant, with a reason, and render no button. Zero buttons is the pass.
|
|
42
42
|
// wired-client the hosted shape: address assistants live, stdio ones honestly absent.
|
|
43
43
|
// wired-staff everything on offer at once — the widest bijection.
|
|
44
|
+
import { navigateOrRefuse } from './headless-page.mjs' // tracker issue 227 — Page.navigate returns an errorText, and nothing read it
|
|
44
45
|
import { createServer } from 'node:http'
|
|
45
46
|
import { reapOnExit } from "../shared/reap-on-exit.mjs"; // — a detached group dies with this script
|
|
46
47
|
import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
@@ -350,7 +351,7 @@ for (const state of Object.keys(STATES)) {
|
|
|
350
351
|
mints = 0
|
|
351
352
|
await cmd('Page.navigate', { url: 'about:blank' })
|
|
352
353
|
await new Promise((r) => setTimeout(r, 150))
|
|
353
|
-
await cmd
|
|
354
|
+
await navigateOrRefuse(cmd, `${origin}/portal/ai`, { what: 'ai-page-render-check' })
|
|
354
355
|
await new Promise((r) => setTimeout(r, 1400))
|
|
355
356
|
|
|
356
357
|
const access = accessFor(STATES[state])
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Because nothing else in this repo can answer the question. portal-ui runs `node --test` with type
|
|
11
11
|
// stripping and carries no jsdom and no React test renderer — Node cannot import a `.tsx` at all — so the
|
|
12
|
+
import { navigateOrRefuse } from './headless-page.mjs' // tracker issue 227 — Page.navigate returns an errorText, and nothing read it
|
|
12
13
|
import { reapOnExit } from "../shared/reap-on-exit.mjs"; // — a detached group dies with this script
|
|
13
14
|
// four source-text tests over Clearances.tsx can prove a string is in a file and nothing more. They
|
|
14
15
|
// cannot see a width, an alignment, or a scrollbar, which is precisely what and are about.
|
|
@@ -318,7 +319,7 @@ const cmd = (method, params) => new Promise((r) => { const i = ++id; pending.set
|
|
|
318
319
|
const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value ?? null
|
|
319
320
|
|
|
320
321
|
const reload = async () => {
|
|
321
|
-
await cmd
|
|
322
|
+
await navigateOrRefuse(cmd, `${origin}/portal/clearances`, { what: 'clearances-render-check' })
|
|
322
323
|
await new Promise((r) => setTimeout(r, 1800))
|
|
323
324
|
}
|
|
324
325
|
|
package/scripts/env-audit.mjs
CHANGED
|
@@ -244,6 +244,26 @@ export const SYSTEM_OWNED = new Set([
|
|
|
244
244
|
// pushed back is right to have made this an explicit decision rather than an omission: they ARE read
|
|
245
245
|
// by product code, and the only honest answers were a row or this list.
|
|
246
246
|
"NO_COLOR", "FORCE_COLOR",
|
|
247
|
+
// ── THE GITHUB ACTIONS RUNTIME (tracker issue 213) ──────────────────────────────────────────────
|
|
248
|
+
//
|
|
249
|
+
// `CI` above is already here for exactly this reason; these two arrived with the release scripts and
|
|
250
|
+
// want the same answer. GitHub sets both INSIDE a workflow run — `GITHUB_OUTPUT` is the step-output
|
|
251
|
+
// file the runner creates, `GITHUB_REPOSITORY` the owner/name of the repository the run belongs to —
|
|
252
|
+
// and `scripts/release-await-cut.mjs`, `release-cut-decision.mjs` and `release-version-pr-checks.mjs`
|
|
253
|
+
// read them there.
|
|
254
|
+
//
|
|
255
|
+
// A row in `.env.example` for `GITHUB_OUTPUT` would tell an operator to set a variable GitHub sets
|
|
256
|
+
// for them, on a machine where it has no meaning at all. That makes the catalogue LESS true, not more
|
|
257
|
+
// — and `INSTALL.md §8` points a new user at that file as the register they must read. The ratchet
|
|
258
|
+
// was right that these are undocumented; the honest answer is the one `NO_COLOR` got, not a row.
|
|
259
|
+
//
|
|
260
|
+
// They surfaced only when the withheld `ops/` bucket was laid back over the public tree, so these
|
|
261
|
+
// arms had had no subject since the cut. Not a regression, and not a documentation gap.
|
|
262
|
+
//
|
|
263
|
+
// NARROW ON PURPOSE — these two names, not a `GITHUB_*` prefix. This list is closed so that a name
|
|
264
|
+
// genuinely ours cannot vanish from the audit by resembling a system name, and a prefix arm here
|
|
265
|
+
// would silently swallow any future `GITHUB_`-prefixed variable this product did come to own.
|
|
266
|
+
"GITHUB_OUTPUT", "GITHUB_REPOSITORY",
|
|
247
267
|
]);
|
|
248
268
|
|
|
249
269
|
/** A file that ships and runs in production, as opposed to one that only ever runs a test. */
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// headless-page.mjs — did the browser open the page we asked for, or something of its own?
|
|
5
|
+
//
|
|
6
|
+
// (and, since tracker issue 227 criteria 3-4, whether this box can draw what that page says)
|
|
7
|
+
//
|
|
8
|
+
// ── WHY THIS EXISTS (tracker issue 227) ─────────────────────────────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// Seven scripts drive headless Chrome and none of them asked. They cannot ask the obvious way: Chrome is
|
|
11
|
+
// launched with the file URL as a COMMAND-LINE ARGUMENT, so there is no navigation call whose response
|
|
12
|
+
// could be checked. The page simply becomes whatever Chrome ends up showing.
|
|
13
|
+
//
|
|
14
|
+
// What it ends up showing, when the file cannot be read, is Chrome's own interstitial — and that page
|
|
15
|
+
// has an `<h1>`. `report-screenshot.mjs` asserted `document.querySelector("h1")` and nothing else, so it
|
|
16
|
+
// photographed `ERR_ACCESS_DENIED`, wrote 38 KB of grey error page over the README's example frame, and
|
|
17
|
+
// exited 0. Measured: the successful run and the failed one differed in the log by an anchor offset and
|
|
18
|
+
// a font count, neither of which was asserted on.
|
|
19
|
+
//
|
|
20
|
+
// Six of the seven were saved only by an unrelated content assertion that happened to be specific enough.
|
|
21
|
+
// That is luck, and it is per-script: the next assertion somebody writes to be tolerant removes it.
|
|
22
|
+
//
|
|
23
|
+
// ── THE DISCRIMINATOR IS THE ADDRESS, NOT THE CONTENT ───────────────────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// `location.href` reads `chrome-error://chromewebdata/` on the interstitial and the requested `file://`
|
|
26
|
+
// URL on a real load. It is the one thing the error page cannot fake, because it is not part of the
|
|
27
|
+
// document — an `<h1>`, a `<title>`, a body class are all things an arbitrary HTML page can carry, and an
|
|
28
|
+
// error page IS an arbitrary HTML page.
|
|
29
|
+
//
|
|
30
|
+
// A MARKER IS STILL REQUIRED, because the address only proves Chrome opened the file. A file that exists,
|
|
31
|
+
// is readable, and is not the artefact this script is about would pass the address check — an empty
|
|
32
|
+
// render, a stale page, a half-written document. So the caller names one thing that only its own artefact
|
|
33
|
+
// carries, and the verdict says which of the two failed.
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from "node:child_process";
|
|
36
|
+
|
|
37
|
+
/** Chrome's own error pages live under this scheme. Nothing a real document is served from does. */
|
|
38
|
+
export const CHROME_ERROR_SCHEME = "chrome-error:";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* PURE. Given what the page says about itself, is it the document we asked for?
|
|
42
|
+
*
|
|
43
|
+
* Separated from the evaluation so every branch can be driven — the whole finding here is a check that
|
|
44
|
+
* returned a verdict about a page it never identified, and an arm that could only exercise this through
|
|
45
|
+
* a real browser would be the same shape one level up.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} href `location.href` as the page reports it
|
|
48
|
+
* @param {string} expected the `file://` URL the caller asked Chrome to open
|
|
49
|
+
* @param {boolean|null} marker did the caller's own content marker resolve? `null` means not asked
|
|
50
|
+
* @param {string} markerName what the marker is, for the message
|
|
51
|
+
*/
|
|
52
|
+
export function pageVerdict({ href = "", expected = "", marker = null, markerName = "the page's own content", errorText = null } = {}) {
|
|
53
|
+
const said = String(href ?? "");
|
|
54
|
+
// ── THE TWO SHAPES IN THIS REPOSITORY ───────────────────────────────────────────────────────────
|
|
55
|
+
//
|
|
56
|
+
// Four of these scripts call `Page.navigate`, which RETURNS an `errorText` on failure and which none
|
|
57
|
+
// of them read. Three launch Chrome with the URL as an argument and have no response at all. One
|
|
58
|
+
// verdict serves both: `errorText` is checked when the caller has one, and the address is checked
|
|
59
|
+
// either way — because a failed `Page.navigate` can also leave the page at `about:blank`, which no
|
|
60
|
+
// error text describes and which a content check reads as an empty document rather than a failure.
|
|
61
|
+
if (errorText) {
|
|
62
|
+
return { ok: false, kind: "navigate-failed",
|
|
63
|
+
why: `chrome refused to navigate to ${expected}: ${errorText}. The page is whatever it was showing `
|
|
64
|
+
+ "before, so anything measured now is about the wrong document." };
|
|
65
|
+
}
|
|
66
|
+
if (!said) {
|
|
67
|
+
return { ok: false, kind: "silent",
|
|
68
|
+
why: "the page reported no address at all, so nothing here identifies what was captured. A "
|
|
69
|
+
+ "screenshot taken now certifies an unknown document." };
|
|
70
|
+
}
|
|
71
|
+
if (said.startsWith(CHROME_ERROR_SCHEME)) {
|
|
72
|
+
return { ok: false, kind: "chrome-error",
|
|
73
|
+
why: `chrome could not open ${expected} and is showing ITS OWN error page (${said}). Whatever was `
|
|
74
|
+
+ "captured is Chrome's interstitial, not the artefact — and that page carries an `<h1>`, a "
|
|
75
|
+
+ "`<title>` and a body, so a content check alone reads it as a success." };
|
|
76
|
+
}
|
|
77
|
+
// NORMALISED ON BOTH SIDES. Chrome resolves and percent-encodes a `file://` argument, so a raw string
|
|
78
|
+
// comparison fails on a path with a space and reports "the wrong document" about the right one.
|
|
79
|
+
const norm = (u) => { try { return new URL(u).href; } catch { return String(u); } };
|
|
80
|
+
if (expected && norm(said) !== norm(expected)) {
|
|
81
|
+
return { ok: false, kind: "wrong-document",
|
|
82
|
+
why: `chrome is showing ${said}, and this run asked for ${expected}. A redirect, a stale tab or a `
|
|
83
|
+
+ "second page target — whichever it is, the frame is not of the document this script names." };
|
|
84
|
+
}
|
|
85
|
+
if (marker === false) {
|
|
86
|
+
return { ok: false, kind: "not-the-artefact",
|
|
87
|
+
why: `chrome opened ${said} and ${markerName} is not in it. The address is right and the CONTENT is `
|
|
88
|
+
+ "not what this script is about — an empty render, a stale file, or a document half written." };
|
|
89
|
+
}
|
|
90
|
+
if (marker === null) {
|
|
91
|
+
return { ok: false, kind: "unmarked",
|
|
92
|
+
why: "no content marker was asked for, so this run proves only that a file opened. Name one thing "
|
|
93
|
+
+ "the artefact carries and nothing else does — an address alone cannot tell an artefact from any "
|
|
94
|
+
+ "other readable file." };
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, kind: "loaded", why: `${said} is open and ${markerName} is in it.` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Ask the live page, then judge. `evaluate` runs an expression and returns its value.
|
|
101
|
+
*
|
|
102
|
+
* The caller passes its own CDP `Runtime.evaluate` wrapper, because each of these scripts built its own
|
|
103
|
+
* handshake before this file existed and rewriting seven of them to share one is a bigger change than the
|
|
104
|
+
* defect warrants. What they must share is the QUESTION.
|
|
105
|
+
*/
|
|
106
|
+
export async function assertPageLoaded(evaluate, { expected, marker = null, markerName = "the page's own content", what = "this page", errorText = null } = {}) {
|
|
107
|
+
const href = await evaluate("location.href");
|
|
108
|
+
const found = marker == null ? null : Boolean(await evaluate(`Boolean(${marker})`));
|
|
109
|
+
const verdict = pageVerdict({ href, expected, marker: found, markerName, errorText });
|
|
110
|
+
if (!verdict.ok) {
|
|
111
|
+
console.error(`${what}: ${verdict.why}`);
|
|
112
|
+
return { ...verdict, href };
|
|
113
|
+
}
|
|
114
|
+
return { ...verdict, href };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Navigate, and refuse if chrome says it could not.
|
|
119
|
+
*
|
|
120
|
+
* `Page.navigate` RETURNS `{ frameId, loaderId, errorText }`, and every caller in this repository threw
|
|
121
|
+
* the result away. So a portal that was not listening, a DNS failure, a refused connection — each left
|
|
122
|
+
* the page showing whatever it had before, and the assertions that followed measured the previous page
|
|
123
|
+
* or an empty one. Six of these scripts were saved from reporting a pass by an unrelated content
|
|
124
|
+
* assertion that happened to be specific enough; that is luck, per script, and the next person to write
|
|
125
|
+
* a more tolerant assertion removes it.
|
|
126
|
+
*
|
|
127
|
+
* THROWS rather than returning a verdict, because there is nothing sensible for a caller to do with a
|
|
128
|
+
* navigation that did not happen, and the alternative — a boolean somebody forgets to read — is the
|
|
129
|
+
* shape this fixes.
|
|
130
|
+
*/
|
|
131
|
+
export async function navigateOrRefuse(cmd, url, { what = "this page" } = {}) {
|
|
132
|
+
const r = await cmd("Page.navigate", { url });
|
|
133
|
+
const errorText = r?.result?.errorText ?? r?.errorText ?? null;
|
|
134
|
+
if (errorText) {
|
|
135
|
+
const verdict = pageVerdict({ errorText, expected: url });
|
|
136
|
+
throw new Error(`${what}: ${verdict.why}`);
|
|
137
|
+
}
|
|
138
|
+
return r;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Does this dumped DOM belong to chrome's own error page?
|
|
143
|
+
*
|
|
144
|
+
* For the callers that use `--dump-dom` rather than CDP: there is no `location.href` to ask, only the
|
|
145
|
+
* bytes chrome printed. Chrome's interstitial is recognisable by the error-code element it always
|
|
146
|
+
* carries and by its `neterror`/`chrome-error` markers — none of which a document we authored has.
|
|
147
|
+
*
|
|
148
|
+
* DELIBERATELY NARROW. A page that merely CONTAINS the words "error" or "denied" is not this; a report
|
|
149
|
+
* about a refused search would say both. What is matched is chrome's own furniture.
|
|
150
|
+
*/
|
|
151
|
+
export function chromeErrorPage(dom = "") {
|
|
152
|
+
const t = String(dom);
|
|
153
|
+
return /chrome-error:\/\//.test(t)
|
|
154
|
+
|| /id="?main-frame-error"?/.test(t)
|
|
155
|
+
|| /jstcache=|<body[^>]*\bid="?neterror"?/.test(t);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── CAN THIS BOX DRAW WHAT THE PAGE SAYS? (tracker issue 227, criteria 3 and 4) ──────────────────────
|
|
159
|
+
//
|
|
160
|
+
// The default demo product is a full-country search, and its report carries the mark's native-script
|
|
161
|
+
// renderings — ベンクリ, ベンコリ, ヴェンコリ. They are LOAD-BEARING: the verdict sentence reads "A live
|
|
162
|
+
// Japanese class 9 registration reading ベンクリ covers measuring and testing instruments".
|
|
163
|
+
//
|
|
164
|
+
// On a box with no CJK-capable font those render as `□□□`, twice in the captured frame, and nothing
|
|
165
|
+
// says so. The script's own comment already names this class of failure — "the failure is a screenshot
|
|
166
|
+
// in the wrong typeface that nobody notices until it is in the README" — and it waits for
|
|
167
|
+
// `document.fonts.ready` to prevent it. That solved the TYPEFACE problem and left the WRITING-SYSTEM one,
|
|
168
|
+
// in the same script, with the same failure mode.
|
|
169
|
+
//
|
|
170
|
+
// ASKED OF FONTCONFIG, not of the page. `document.fonts` reports the faces a page ASKED for and got; it
|
|
171
|
+
// says nothing about whether the glyphs exist. `fc-list :lang=ja` answers the question actually being
|
|
172
|
+
// asked — can anything on this box draw these characters — and it is the same source a reader would
|
|
173
|
+
// check by hand.
|
|
174
|
+
|
|
175
|
+
/** Han, Hiragana, Katakana, Hangul — the ranges a Latin-only font set leaves as tofu. */
|
|
176
|
+
const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/gu;
|
|
177
|
+
|
|
178
|
+
/** How many characters in this text need a CJK-capable font. */
|
|
179
|
+
export function cjkCharsIn(text = "") {
|
|
180
|
+
return (String(text).match(CJK) ?? []).length;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* PURE. Given what the page needs and what the box has, is the frame trustworthy?
|
|
185
|
+
*
|
|
186
|
+
* `covering` is the number of fonts fontconfig reports for the writing system — `null` means the caller
|
|
187
|
+
* could not ask, which is NOT zero: a box where `fc-list` is missing is a box this cannot judge, and
|
|
188
|
+
* reporting it as "no coverage" would refuse a machine that may be fine.
|
|
189
|
+
*/
|
|
190
|
+
export function cjkVerdict({ cjkChars = 0, covering = 0, sample = "" } = {}) {
|
|
191
|
+
if (!cjkChars) return { ok: true, kind: "no-cjk", why: "the page carries no CJK characters." };
|
|
192
|
+
if (covering === null) {
|
|
193
|
+
return { ok: true, kind: "unknown-coverage",
|
|
194
|
+
why: `the page carries ${cjkChars} CJK character(s) and this run could not ask fontconfig what can `
|
|
195
|
+
+ "draw them. Not a refusal — a box that cannot be asked is not a box known to be missing fonts — "
|
|
196
|
+
+ "but the frame is unverified on that point." };
|
|
197
|
+
}
|
|
198
|
+
if (covering > 0) {
|
|
199
|
+
return { ok: true, kind: "covered",
|
|
200
|
+
why: `the page carries ${cjkChars} CJK character(s) and ${covering} installed font(s) cover them.` };
|
|
201
|
+
}
|
|
202
|
+
return { ok: false, kind: "tofu",
|
|
203
|
+
why: `the page carries ${cjkChars} CJK character(s)${sample ? ` (${sample})` : ""} and NO installed `
|
|
204
|
+
+ "font can draw them — `fc-list :lang=ja` reports none. They render as empty boxes, and the frame "
|
|
205
|
+
+ "would go out with the mark's own native-script rendering missing. Install a CJK font (on Debian "
|
|
206
|
+
+ "and Ubuntu: `fonts-noto-cjk`), or set XDG_DATA_HOME to a directory holding one." };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* How many installed fonts cover a writing system, per fontconfig — or `null` if we could not ask.
|
|
211
|
+
*
|
|
212
|
+
* `null` IS THE POINT. `fc-list` missing, or a fontconfig that errors, is a box this cannot judge, and
|
|
213
|
+
* collapsing that into 0 would refuse a machine that may be perfectly able to draw the page. The caller
|
|
214
|
+
* treats the two differently, which is the whole reason this returns three values and not a number.
|
|
215
|
+
*/
|
|
216
|
+
export function fontsCovering(lang = "ja", { run } = {}) {
|
|
217
|
+
try {
|
|
218
|
+
const out = run
|
|
219
|
+
? run(["-f", "%{file}\\n", `:lang=${lang}`])
|
|
220
|
+
: execFileSync("fc-list", ["-f", "%{file}\\n", `:lang=${lang}`], { encoding: "utf8", timeout: 20_000 });
|
|
221
|
+
return String(out).split("\n").map((l) => l.trim()).filter(Boolean).length;
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// Exits non-zero on the first state that fails to draw, scrolls sideways, or renders a pip count that
|
|
19
19
|
// contradicts the run it is drawn from.
|
|
20
20
|
|
|
21
|
+
import { navigateOrRefuse } from './headless-page.mjs' // tracker issue 227 — Page.navigate returns an errorText, and nothing read it
|
|
21
22
|
import { createServer } from 'node:http'
|
|
22
23
|
import { reapOnExit } from "../shared/reap-on-exit.mjs"; // — a detached group dies with this script
|
|
23
24
|
import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
|
|
@@ -377,7 +378,7 @@ for (const [name, spec] of Object.entries(STATES)) {
|
|
|
377
378
|
}
|
|
378
379
|
|
|
379
380
|
await evalIn(`window.__renderCheckDoc = 1`)
|
|
380
|
-
await cmd
|
|
381
|
+
await navigateOrRefuse(cmd, `${origin}/portal/home`, { what: 'home-render-check' })
|
|
381
382
|
if (!await homeReady('navigate')) continue
|
|
382
383
|
await evalIn(`localStorage.setItem('cordillera-theme', ${JSON.stringify(theme)})`)
|
|
383
384
|
await evalIn(`window.__renderCheckDoc = 1`)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
4
|
+
//
|
|
5
|
+
// Mints `driver/test/fixtures/reference-strip-backlog.json` — the per-file floor under the repair of
|
|
6
|
+
// the sentences the reference strip broke (tracker issue 185).
|
|
7
|
+
//
|
|
8
|
+
// Run it AFTER repairing lines, never to make a red arm green: the arm it feeds refuses any count that
|
|
9
|
+
// went UP, so re-minting is how a repair is recorded, not how a regression is absorbed.
|
|
10
|
+
//
|
|
11
|
+
// node scripts/mint-reference-strip-backlog.mjs [--check]
|
|
12
|
+
//
|
|
13
|
+
// `--check` re-derives and exits non-zero if the committed table disagrees with the tree, which is what
|
|
14
|
+
// CI runs. Without it, the table is rewritten.
|
|
15
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { SIGNATURES, censusOf } from "../driver/reference-strip-signatures.mjs";
|
|
20
|
+
|
|
21
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
|
+
const TABLE = join(ROOT, "driver/test/fixtures/reference-strip-backlog.json");
|
|
23
|
+
|
|
24
|
+
const tracked = execFileSync("git", ["-C", ROOT, "ls-files"], { encoding: "utf8", maxBuffer: 1 << 28 })
|
|
25
|
+
.split("\n").filter(Boolean);
|
|
26
|
+
const minted = censusOf(ROOT, tracked, (f) => readFileSync(join(ROOT, f), "utf8"));
|
|
27
|
+
|
|
28
|
+
if (process.argv.includes("--check")) {
|
|
29
|
+
const have = JSON.parse(readFileSync(TABLE, "utf8"));
|
|
30
|
+
const a = JSON.stringify(have.files), b = JSON.stringify(minted.files);
|
|
31
|
+
if (a !== b || have.total !== minted.total) {
|
|
32
|
+
console.error("reference-strip backlog is STALE against the tree.");
|
|
33
|
+
console.error(` committed total ${have.total}, tree has ${minted.total}`);
|
|
34
|
+
console.error(" re-mint with: node scripts/mint-reference-strip-backlog.mjs");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
console.log(`reference-strip backlog: current — ${minted.total} line(s) still to repair`);
|
|
38
|
+
} else {
|
|
39
|
+
writeFileSync(TABLE, JSON.stringify({ signatures: SIGNATURES.map((s) => s.name), ...minted }, null, 2) + "\n");
|
|
40
|
+
console.log(`minted ${TABLE}: ${minted.total} line(s) across ${Object.keys(minted.files).length} file(s)`);
|
|
41
|
+
}
|
|
@@ -54,11 +54,33 @@ import { cutDecision, versionAtHead, tagsHere } from "./release-cut-decision.mjs
|
|
|
54
54
|
/**
|
|
55
55
|
* Default bound: fifteen minutes at thirty-second steps. Both are arguments so an arm can drive the loop.
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* TWENTY-FIVE, RAISED FROM FIFTEEN (tracker issue 247), because this waits for the version pull
|
|
58
|
+
* request's OWN CI and that is what it must clear. Measured over the first three cuts, the wait held
|
|
59
|
+
* 552 s, 622 s and 686 s against a 900 s budget — rising every time, and the thing it waits on is the
|
|
60
|
+
* offline suite, which grows on purpose every time anybody adds an arm. The margin was one slow queue.
|
|
61
|
+
*
|
|
62
|
+
* AND THE FAILURE IS SILENT, which is why the margin has to be generous rather than adequate. Running
|
|
63
|
+
* out is a quiet exit 0 by design — an ordinary "nothing merged" must not read as a fault — so the
|
|
64
|
+
* first time this budget is exceeded, the release is simply stranded behind a green tick. There is no
|
|
65
|
+
* red to notice. The cron floor underneath would eventually publish it, which makes the silence worse
|
|
66
|
+
* rather than better: the version ships late, from a different run, with nobody told why.
|
|
67
|
+
*
|
|
68
|
+
* THE JOB'S `timeout-minutes` MUST EXCEED THIS, with room for the checkout and install above it. A
|
|
69
|
+
* budget smaller than its own longest step cancels the job at the moment it was about to publish, and a
|
|
70
|
+
* cancelled run reads as neither a success nor a failure to anybody scanning the list. The comment here
|
|
71
|
+
* used to say the job was capped at 30 while the job actually said 25 — harmless at a 15 minute wait,
|
|
72
|
+
* and exactly the sort of thing that stops being harmless when somebody raises one number and believes
|
|
73
|
+
* a sentence about the other. `MIN_JOB_MARGIN_MS` is what an arm holds the pair to now.
|
|
74
|
+
*/
|
|
75
|
+
export const WAIT_MS = 25 * 60 * 1000;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How far the job's budget must exceed the wait's: enough for the checkout, the install and the pack
|
|
79
|
+
* that surround it. Five minutes, which is generous against the ~90 s those actually take, because the
|
|
80
|
+
* cost of being wrong in this direction is a cancelled publish and the cost of being wrong in the other
|
|
81
|
+
* is a runner held slightly longer.
|
|
60
82
|
*/
|
|
61
|
-
export const
|
|
83
|
+
export const MIN_JOB_MARGIN_MS = 5 * 60 * 1000;
|
|
62
84
|
|
|
63
85
|
/**
|
|
64
86
|
* The bound, overridable for one caller only: the dry-run rehearsal.
|
|
@@ -111,6 +133,70 @@ export async function awaitCut({ refresh, read, sleep, waitMs = WAIT_MS, stepMs
|
|
|
111
133
|
|
|
112
134
|
const git = (args) => execFileSync("git", args, { encoding: "utf8" });
|
|
113
135
|
|
|
136
|
+
/**
|
|
137
|
+
* One read of `main`: the version it carries, whether that version is tagged, and WHICH COMMIT said so.
|
|
138
|
+
*
|
|
139
|
+
* THE COMMIT IS READ IN THE SAME PASS AS THE VERSION, and that is the whole point of this function
|
|
140
|
+
* existing rather than being three calls at the call site (tracker issue 238). The job below used to
|
|
141
|
+
* check out `main` by name after this loop returned, so a commit landing in between — one that moves no
|
|
142
|
+
* version, an instrument fix with no note — was packed and published under a number whose changelog
|
|
143
|
+
* never described it. Nothing downstream could see it: the tip check compares VERSIONS, and the version
|
|
144
|
+
* had not moved. Naming the commit here is what makes the two ends of the pipeline talk about the same
|
|
145
|
+
* tree.
|
|
146
|
+
*
|
|
147
|
+
* No fetch happens between the two reads, so they cannot straddle one.
|
|
148
|
+
*
|
|
149
|
+
* EVERY READER IS AN ARGUMENT, and that is not decoration. An arm that injected only `run` still asked
|
|
150
|
+
* real git for the version and the tags, so it answered differently on a box where `main` is tagged than
|
|
151
|
+
* on a runner where the checkout has neither `origin/main` nor tags — green here, red there, for reasons
|
|
152
|
+
* that have nothing to do with what it was checking. This file's own suite header warns about exactly
|
|
153
|
+
* that shape, and one of these arms was written into it anyway.
|
|
154
|
+
*/
|
|
155
|
+
export function versionBumpCommit({ version, ref = "origin/main", run = git, versionAt = versionAtHead, maxWalk = 100 }) {
|
|
156
|
+
const shas = run(["rev-list", "--first-parent", `-n${maxWalk}`, ref]).trim().split("\n").filter(Boolean);
|
|
157
|
+
let answer = null;
|
|
158
|
+
let sawTheChange = false;
|
|
159
|
+
for (const sha of shas) {
|
|
160
|
+
if (versionAt({ ref: sha }) !== version) { sawTheChange = true; break; }
|
|
161
|
+
// KEEP WALKING PAST THE FIRST MATCH. Every commit landing after the bump and before this read also
|
|
162
|
+
// carries the version — that is precisely the class of commit this exists to leave out — so the
|
|
163
|
+
// answer is the OLDEST consecutive one, not the newest.
|
|
164
|
+
answer = sha;
|
|
165
|
+
}
|
|
166
|
+
// A WALK THAT NEVER SAW THE VERSION CHANGE HAS NOT FOUND THE BUMP; it has run out of road. The oldest
|
|
167
|
+
// commit it happened to reach carries the version by coincidence of the window, and publishing that
|
|
168
|
+
// would ship a tree from before the release. An absence is a finding.
|
|
169
|
+
return sawTheChange ? answer : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function readMain({ run = git, versionAt = versionAtHead, tags = tagsHere, decide = cutDecision } = {}) {
|
|
173
|
+
const sha = run(["rev-parse", "origin/main"]).trim();
|
|
174
|
+
// CHECKED BEFORE THE VERSION IS READ, so a checkout with no `origin/main` refuses here rather than
|
|
175
|
+
// going on to answer confidently about a tree it could not name. `git rev-parse` prints the name back
|
|
176
|
+
// when it cannot resolve it, so the failure looks like a value rather than like an error.
|
|
177
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) {
|
|
178
|
+
throw new Error(`release-await-cut: \`git rev-parse origin/main\` answered "${sha.slice(0, 80)}", which is `
|
|
179
|
+
+ "not a commit. The publish below checks out what this reports, so a name it cannot resolve must "
|
|
180
|
+
+ "refuse here rather than resolve to something else there.");
|
|
181
|
+
}
|
|
182
|
+
const d = decide({ version: versionAt({ ref: "origin/main" }), tags: tags() });
|
|
183
|
+
// NOT THE TIP. `origin/main` is where the branch points in this pass, and a commit that landed after
|
|
184
|
+
// the version bump carries the same version — so the tip check downstream, which compares versions,
|
|
185
|
+
// passes on exactly the commit that made the tarball disagree with its changelog. The answer is the
|
|
186
|
+
// commit that MOVED the version, which is the version pull request's merge.
|
|
187
|
+
//
|
|
188
|
+
// Only asked when there is something to publish. On the ordinary "nothing merged" answer the tip is
|
|
189
|
+
// what a reader wants recorded, and there is no version whose bump could be looked for.
|
|
190
|
+
if (!d.cut) return { ...d, sha, tip: sha };
|
|
191
|
+
const bump = versionBumpCommit({ version: d.version, run, versionAt });
|
|
192
|
+
if (!bump) {
|
|
193
|
+
throw new Error(`release-await-cut: main carries ${d.version} but no commit in the last 100 could be `
|
|
194
|
+
+ "found that moved it there. The publish below checks out what this reports, and reporting the "
|
|
195
|
+
+ "branch tip instead would publish whatever else has landed since.");
|
|
196
|
+
}
|
|
197
|
+
return { ...d, sha: bump, tip: sha };
|
|
198
|
+
}
|
|
199
|
+
|
|
114
200
|
function main() {
|
|
115
201
|
const out = process.env.GITHUB_OUTPUT;
|
|
116
202
|
const started = Date.now();
|
|
@@ -120,7 +206,7 @@ function main() {
|
|
|
120
206
|
// tags never arrived answers "no tag" about every version there has ever been. That is the one wrong
|
|
121
207
|
// answer this pipeline cannot afford, so it is refreshed on every pass rather than once at checkout.
|
|
122
208
|
refresh: async () => { git(["fetch", "--no-tags", "--prune", "origin", "+refs/heads/main:refs/remotes/origin/main"]); git(["fetch", "--tags", "--force", "origin"]); },
|
|
123
|
-
read: () =>
|
|
209
|
+
read: () => readMain(),
|
|
124
210
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
125
211
|
now: () => Date.now() - started,
|
|
126
212
|
}).catch((e) => {
|
|
@@ -137,7 +223,7 @@ function main() {
|
|
|
137
223
|
+ `(${String(e?.message ?? e).slice(0, 200)}). This is a failure to LOOK, not a finding that `
|
|
138
224
|
+ "nothing was cut — nothing downstream may treat it as one.");
|
|
139
225
|
process.exitCode = 2;
|
|
140
|
-
if (out) appendFileSync(out, "cut=false\nversion=\nlooked=false\n");
|
|
226
|
+
if (out) appendFileSync(out, "cut=false\nversion=\nsha=\nlooked=false\n");
|
|
141
227
|
}).then((r) => {
|
|
142
228
|
if (!r) return;
|
|
143
229
|
const secs = Math.round(r.waitedMs / 1000);
|
|
@@ -148,7 +234,9 @@ function main() {
|
|
|
148
234
|
// `looked` SEPARATES the two negatives above: a loop that ran and found nothing merged, from one
|
|
149
235
|
// that could not read main at all. The publish job below requires `cut=true`, so neither publishes —
|
|
150
236
|
// but a reader deciding whether a release went missing needs to know which of the two happened.
|
|
151
|
-
|
|
237
|
+
// `sha` IS WRITTEN ON BOTH ANSWERS, not only on a cut. It records which commit this loop's verdict is
|
|
238
|
+
// about, so a run that published nothing can still be read back against the tree it looked at.
|
|
239
|
+
if (out) appendFileSync(out, `cut=${r.cut ? "true" : "false"}\nversion=${r.version}\nsha=${r.sha ?? ""}\nlooked=true\n`);
|
|
152
240
|
});
|
|
153
241
|
}
|
|
154
242
|
|
|
@@ -131,7 +131,31 @@ export function checksVerdict({ checkRuns = [], workflowRuns = [], policy = null
|
|
|
131
131
|
* incident, and this is the difference between a false red on the version job — cheap, loud, retried by
|
|
132
132
|
* the next push — and a red that means what it says.
|
|
133
133
|
*/
|
|
134
|
-
|
|
134
|
+
/**
|
|
135
|
+
* This file's own window: 32 attempts at 15 s, or eight minutes.
|
|
136
|
+
*
|
|
137
|
+
* NAMED SO AN ARM CAN COUPLE THE JOB'S BUDGET TO IT (tracker issue 247). The `version` job's
|
|
138
|
+
* `timeout-minutes` has to contain THIS wait, and the arm that claimed to check that compared the job
|
|
139
|
+
* against `release-await-cut.mjs`'s constant instead — a different wait, in a different job, which this
|
|
140
|
+
* job never runs. It held by luck and would have failed the moment that unrelated number was raised.
|
|
141
|
+
*/
|
|
142
|
+
export const CHECK_ATTEMPTS = 32;
|
|
143
|
+
export const CHECK_EVERY_MS = 15_000;
|
|
144
|
+
export const CHECKS_WINDOW_MS = CHECK_ATTEMPTS * CHECK_EVERY_MS;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What the `version` job spends around this wait: checkout, install, the changesets action that cuts the
|
|
148
|
+
* pull request, and the cut/awaited steps either side of it.
|
|
149
|
+
*
|
|
150
|
+
* This margin belongs to THIS file for the same reason the window does. `release-await-cut.mjs` exports a
|
|
151
|
+
* margin of its own with the same name-shape and a different subject — the checkout and install around the
|
|
152
|
+
* `awaited` job's wait. Coupling the version job's budget to that one repeats the very defect this issue
|
|
153
|
+
* closes, one size smaller: raising the other job's margin would move this job's requirement, about a job
|
|
154
|
+
* whose behaviour had not changed. Two jobs, two waits, two margins, and no number shared between them.
|
|
155
|
+
*/
|
|
156
|
+
export const CHECKS_JOB_MARGIN_MS = 4 * 60 * 1000;
|
|
157
|
+
|
|
158
|
+
export async function waitForChecks({ read, sleep, attempts = CHECK_ATTEMPTS, everyMs = CHECK_EVERY_MS } = {}) {
|
|
135
159
|
if (typeof read !== "function") throw new Error("release-version-pr-checks: waitForChecks needs a read()");
|
|
136
160
|
let seen = null;
|
|
137
161
|
for (let i = 1; i <= attempts; i++) {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// Needs `google-chrome` (on the VM). Same mechanism as render-check.mjs: a page computes its verdict
|
|
23
23
|
// and writes it into <title>, which --dump-dom hands back.
|
|
24
24
|
|
|
25
|
+
import { chromeErrorPage } from "./headless-page.mjs"; // tracker issue 227 — a dumped DOM can be chrome's own error document
|
|
25
26
|
import { execFileSync } from "node:child_process";
|
|
26
27
|
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
27
28
|
import { tmpdir } from "node:os";
|
|
@@ -63,6 +64,17 @@ function main() {
|
|
|
63
64
|
"--virtual-time-budget=8000", "--dump-dom", `file://${join(work, "verify.html")}`,
|
|
64
65
|
], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 120000 });
|
|
65
66
|
|
|
67
|
+
// ── IS THIS OUR PAGE, OR CHROME'S ERROR PAGE? (tracker issue 227) ───────────────────────────────
|
|
68
|
+
//
|
|
69
|
+
// `--dump-dom` prints whatever chrome ended up showing, and a file it could not read produces the
|
|
70
|
+
// interstitial — which has a `<title>` of its own. This was saved from reporting a pass only by the
|
|
71
|
+
// marker below being specific: the title has to parse as JSON with a `kept` array. That is luck, not
|
|
72
|
+
// a check, and the next person to loosen the parse removes it. Said out loud instead.
|
|
73
|
+
if (chromeErrorPage(out)) {
|
|
74
|
+
console.log(`FAILED — chrome could not open the page and dumped its own error document instead. `
|
|
75
|
+
+ `Nothing below is about the report.`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
66
78
|
const m = /<title>(.*?)<\/title>/s.exec(out);
|
|
67
79
|
if (!m || m[1] === "pending") { console.log("FAILED — the page never reported"); process.exit(1); }
|
|
68
80
|
const kept = JSON.parse(m[1]).kept;
|