clearotron 0.3.2-beta.10 → 0.3.2-beta.12
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 +1 -1
- package/CONTRIBUTING.md +1 -0
- package/INSTALL.md +49 -46
- package/README.md +8 -7
- package/bin/clearotron.mjs +14 -0
- package/bin/connect.mjs +68 -3
- package/bin/key.mjs +6 -1
- package/bin/onboard.mjs +51 -14
- package/bin/passphrase.mjs +4 -2
- package/bin/start.mjs +43 -9
- package/build-info.json +2 -2
- package/docs/architecture/05-config-governance.md +1 -1
- package/driver/CHANGELOG.md +416 -388
- package/driver/contract-vocabulary.mjs +5 -5
- package/driver/engine/mcp/recording-server.mjs +21 -1
- package/driver/engine/mcp/supplemental.mjs +22 -5
- package/driver/knockout-next-step.mjs +72 -0
- package/driver/named-band.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/phase0.mjs +16 -7
- package/driver/pipeline-knockout.mjs +26 -0
- package/driver/pipeline.mjs +48 -1
- package/driver/portal-local-auth.mjs +14 -4
- package/driver/portal-report.mjs +21 -2
- package/driver/portal-service.mjs +20 -11
- package/driver/publish/attr.mjs +36 -0
- package/driver/publish/index.mjs +10 -10
- package/driver/publish/parse.mjs +1 -1
- package/driver/publish/render-knockout.mjs +14 -9
- package/driver/publish/render.mjs +9 -9
- package/driver/publish/xlsx.mjs +7 -1
- package/driver/queue-watch-verdict.mjs +1 -1
- package/driver/register-availability.mjs +1 -1
- package/driver/register-plan.mjs +81 -11
- package/driver/result-noun-fields.mjs +3 -1
- package/driver/stages-knockout.mjs +1 -1
- package/driver/suite-census.json +104 -38
- package/driver/unit-inventory.mjs +7 -7
- package/driver/verify-knockout.mjs +0 -27
- package/mcp-server/CHANGELOG.md +27 -19
- package/mcp-server/lib/audit-view.mjs +4 -4
- package/mcp-server/lib/trace.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
- package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/jx/README.md +2 -2
- package/providers/jx-subclass/README.md +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/README.md +5 -10
- package/scripts/ask-ai-render-check.mjs +26 -1
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/dead-names.mjs +17 -16
- package/scripts/e2e.mjs +8 -8
- package/scripts/env-audit.mjs +6 -0
- package/scripts/generated-files-are-current.mjs +35 -4
- package/scripts/live-surface-check.mjs +17 -17
- package/scripts/release-code-scanning-check.mjs +114 -0
- package/scripts/release-visible-check.mjs +117 -0
- package/scripts/repo-writes.mjs +42 -0
- package/scripts/report-sections-render-check.mjs +245 -0
- package/scripts/report-theme-render-check.mjs +31 -3
- package/scripts/settings-render-check.mjs +10 -7
- package/scripts/test-run.mjs +63 -4
- package/shared/client-door.mjs +20 -0
- package/shared/invocation.mjs +2 -2
- package/shared/parent-watch.mjs +33 -0
- package/shared/register-selection.mjs +4 -1
- package/shared/root-doc-commands.mjs +10 -4
- package/shared/running-start.mjs +14 -3
- package/shared/scope.mjs +24 -5
|
@@ -60,6 +60,19 @@ export function treeState(root = ROOT) {
|
|
|
60
60
|
* a path whose STATE changed — staged to modified, say — is reported as having moved, and the paths are
|
|
61
61
|
* returned rather than a count, because two numbers agreeing is not the same as two sets agreeing.
|
|
62
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* The `git status --porcelain` LINES that differ between two readings, status included. `movedPaths`
|
|
65
|
+
* names what moved; this keeps the two-character state, which is what says whether a path is tracked.
|
|
66
|
+
*/
|
|
67
|
+
export function movedLines(before, after) {
|
|
68
|
+
const bag = (s) => { const m = new Map(); for (const l of String(s).split("\n")) if (l.trim()) m.set(l, (m.get(l) ?? 0) + 1); return m; };
|
|
69
|
+
const [b, a] = [bag(before), bag(after)];
|
|
70
|
+
const out = new Set();
|
|
71
|
+
for (const [l, n] of a) if ((b.get(l) ?? 0) !== n) out.add(l);
|
|
72
|
+
for (const [l, n] of b) if ((a.get(l) ?? 0) !== n) out.add(l);
|
|
73
|
+
return [...out];
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
export function movedPaths(before, after) {
|
|
64
77
|
const bag = (s) => {
|
|
65
78
|
const m = new Map();
|
|
@@ -84,6 +97,7 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
|
|
|
84
97
|
const unreadable = [];
|
|
85
98
|
const wrote = [];
|
|
86
99
|
const unattributable = [];
|
|
100
|
+
const neighbours = [];
|
|
87
101
|
for (const m of found) {
|
|
88
102
|
// ── `--check` IS A CONTRACT, AND NOTHING WAS VERIFYING IT ──────────────────────────────────────
|
|
89
103
|
//
|
|
@@ -125,14 +139,25 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
|
|
|
125
139
|
const out = ((r.stdout || "") + (r.stderr || "")).trim();
|
|
126
140
|
if (before !== null && after !== null && before !== after) {
|
|
127
141
|
const moved = movedPaths(before, after);
|
|
128
|
-
|
|
142
|
+
// AN UNTRACKED PATH IS NOT A MINTER'S TO WRITE. A minter that ignores `--check` re-mints its own
|
|
143
|
+
// generated file, which is tracked, so its write reads as a MODIFIED path. A path that appears or
|
|
144
|
+
// goes as untracked (`??`) was written by something else — in the suite, the neighbour test running
|
|
145
|
+
// beside this one — and it touched nothing a minter reads. So it is named, with who it was not, and
|
|
146
|
+
// this minter's answer is read as it came back. Measured on a suite run: `mint-public-residue`
|
|
147
|
+
// printed "the backlog is current" and was named as having changed the tree, because a neighbour
|
|
148
|
+
// had just made a directory inside the checkout.
|
|
149
|
+
if (movedLines(before, after).every((l) => l.startsWith("?? "))) {
|
|
150
|
+
neighbours.push({ m, moved });
|
|
151
|
+
log(` (another process wrote ${moved.join(", ")} while ${m} ran — untracked, so not this minter's output)`);
|
|
152
|
+
} else if (before.trim() === "") {
|
|
129
153
|
wrote.push({ m, out, moved });
|
|
130
154
|
log(` WROTE ${m} (during --check): ${moved.join(", ") || "the tree moved"}`);
|
|
155
|
+
continue;
|
|
131
156
|
} else {
|
|
132
157
|
unattributable.push({ m, moved });
|
|
133
158
|
log(` ? ${m} — the tree moved and this probe cannot say who moved it: ${moved.join(", ") || "paths unknown"}`);
|
|
159
|
+
continue;
|
|
134
160
|
}
|
|
135
|
-
continue;
|
|
136
161
|
}
|
|
137
162
|
// 0 is current, 1 is stale, anything else is a minter that could not look — reported separately,
|
|
138
163
|
// because "I could not read the tree" and "the file is out of date" need different things done.
|
|
@@ -141,11 +166,11 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
|
|
|
141
166
|
unreadable.push({ m, out, code: r.status });
|
|
142
167
|
log(` ? ${m} (exit ${r.status})`);
|
|
143
168
|
}
|
|
144
|
-
return { found, stale, unreadable, wrote, unattributable, empty: false, contractChecked: readTree() !== null };
|
|
169
|
+
return { found, stale, unreadable, wrote, unattributable, neighbours, empty: false, contractChecked: readTree() !== null };
|
|
145
170
|
}
|
|
146
171
|
|
|
147
172
|
function main() {
|
|
148
|
-
const { found, stale, unreadable, wrote, unattributable, empty, contractChecked } = checkAll();
|
|
173
|
+
const { found, stale, unreadable, wrote, unattributable, neighbours, empty, contractChecked } = checkAll();
|
|
149
174
|
if (empty) {
|
|
150
175
|
console.error("generated-files-are-current: no scripts/mint-*.mjs found. Either they moved or the "
|
|
151
176
|
+ "naming changed — and a pass over nothing is not a pass.");
|
|
@@ -154,6 +179,12 @@ function main() {
|
|
|
154
179
|
console.log(`\ngenerated-files-are-current: checked ${found.length} minter(s)`
|
|
155
180
|
+ (contractChecked ? "" : "; the tree could not be read, so nothing verified that `--check` changed nothing"));
|
|
156
181
|
|
|
182
|
+
// NAMED, NOT FAILED: the file, and that the writer was not the minter. The fix belongs to whatever
|
|
183
|
+
// wrote inside the checkout, and the wrapper's own no-writes check is the one that fails it.
|
|
184
|
+
for (const { m, moved } of neighbours ?? []) {
|
|
185
|
+
console.error(`\nnot ${m}: another process wrote ${moved.join(", ")} inside the checkout while it ran — `
|
|
186
|
+
+ `untracked, so not a generated file; this check's answers stand.`);
|
|
187
|
+
}
|
|
157
188
|
for (const { m, out } of [...wrote, ...stale, ...unreadable]) {
|
|
158
189
|
console.error(`\n──── ${m} ────\n${out}`);
|
|
159
190
|
}
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
// because none of them is a property of the source — they are properties of the PROCESSES that are
|
|
12
12
|
// running, the ENVIRONMENT they were started in, and the COMMIT they were started from:
|
|
13
13
|
//
|
|
14
|
-
// •
|
|
14
|
+
// • THE AVAILABILITY MISMATCH — the ops-MCP told every caller that three of five depths were "not switched on". They were
|
|
15
15
|
// switched on; the portal said so on the same box at the same moment. The MCP unit had no
|
|
16
16
|
// EnvironmentFile, so it read availability from an environment it did not have. The validation that
|
|
17
17
|
// was supposed to catch this ran `describe_options` in a plain shell — the one environment that
|
|
18
18
|
// makes it dishonest — and reported 10/10. A probe that talks to the RUNNING service cannot make
|
|
19
19
|
// that mistake.
|
|
20
20
|
//
|
|
21
|
-
// •
|
|
21
|
+
// • THE ROSTER FALLBACK — the ops-MCP had no CLEAROTRON_CUSTOMERS_DIR, silently fell back to the bundled demo roster, and
|
|
22
22
|
// refused every real customer. Nothing anywhere compared the roster a door resolves against the
|
|
23
23
|
// roster on disk.
|
|
24
24
|
//
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
// Stated plainly, because a check that quietly skips a surface is worse than one that says it did:
|
|
36
36
|
//
|
|
37
37
|
// ops-MCP loopback, TRADEMARK_MCP_AUTH_DISABLED — fully reachable, two independent code paths
|
|
38
|
-
// (describe_options → lib/options.mjs, plan_run → lib/plan.mjs).
|
|
38
|
+
// (describe_options → lib/options.mjs, plan_run → lib/plan.mjs). The availability mismatch lived in BOTH, so
|
|
39
39
|
// comparing them to each other is necessary but NOT sufficient…
|
|
40
40
|
// on-disk …which is why the load-bearing comparison is against the flag snapshot, recomputed
|
|
41
41
|
// here through the ENGINE's own productAvailability(). That is the source of truth the
|
|
@@ -47,10 +47,10 @@
|
|
|
47
47
|
//
|
|
48
48
|
// ── it must call the doors AS A REAL CALLER DOES ─────────────────────────────────────────────────────
|
|
49
49
|
//
|
|
50
|
-
// Learned while writing this, and worth stating because it is the same trap as
|
|
50
|
+
// Learned while writing this, and worth stating because it is the same trap as the availability mismatch wearing a different
|
|
51
51
|
// hat. A token-less loopback call to the ops face initialises an accounts-scoped session with NO
|
|
52
52
|
// accounts: `list_profiles` answers `clients: 0` and `plan_run` refuses, because an empty grant covers
|
|
53
|
-
// no account and not the neutral profile either. The first version of this script read that as a live
|
|
53
|
+
// no account and not the neutral profile either. The first version of this script read that as a live roster fallback and was
|
|
54
54
|
// WRONG — the deployment was fine; the caller was not. With the portal's own ops token the same call
|
|
55
55
|
// answers `clients: 10`.
|
|
56
56
|
//
|
|
@@ -97,7 +97,7 @@ import { treeOfRunning } from "../shared/checkout-move.mjs"; // …and
|
|
|
97
97
|
import { findUnitFiles, unitFilePath } from "../driver/unit-files.mjs"; //
|
|
98
98
|
import { managerGroupsVerdict } from "../driver/manager-groups-verdict.mjs"; //
|
|
99
99
|
import { config } from "../driver/driver.config.mjs"; //
|
|
100
|
-
import { probeQueueWatch, probeWorker, probeTimer } from "../driver/queue-watch-probe.mjs"; // · and
|
|
100
|
+
import { probeQueueWatch, probeWorker, probeTimer } from "../driver/queue-watch-probe.mjs"; // · and the units that say HOW this box drains
|
|
101
101
|
import { doorPostureVerdict } from "../mcp-server/door-posture.mjs"; // — a door whose mode came from another door's variables
|
|
102
102
|
import { readDrainerStamp, drainerVerdict, defaultPpidOf } from "../driver/drainer-identity.mjs"; // — the process that EXECUTES runs
|
|
103
103
|
import { readUpdaterStamp, updaterVerdict, resolveUpdaterStampPath, updaterAbsentHere, UPDATER_STAMP_BASENAME } from "../driver/updater-identity.mjs"; // — the mechanism that PLACES commits
|
|
@@ -105,7 +105,7 @@ import { claimerIsAlive } from "../driver/claim-liveness.mjs";
|
|
|
105
105
|
import { processTable } from "../shared/process-table.mjs"; // — /proc is not the only box
|
|
106
106
|
import { envFrom } from "../shared/env-aliases.mjs";
|
|
107
107
|
import { gitTry, treeOf } from "../shared/tree-commit.mjs"; // — a packaged install has no git, and says its commit in build-info.json
|
|
108
|
-
import { exitFor } from "../driver/surface-exit-verdict.mjs"; // — a could
|
|
108
|
+
import { exitFor } from "../driver/surface-exit-verdict.mjs"; // — a check that could not look is not a drift, and they want different things done // — the name a reader is told to set is the one in force
|
|
109
109
|
|
|
110
110
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
111
111
|
const asJson = process.argv.includes("--json");
|
|
@@ -170,12 +170,12 @@ const PORTAL_URL = DOORS.portal.url;
|
|
|
170
170
|
const CLIENT_MCP_URL = DOORS["client door"].url;
|
|
171
171
|
|
|
172
172
|
// The bundled demo roster, as `list_profiles` REPORTS it. A door that resolves exactly this set is a
|
|
173
|
-
// door with no CLEAROTRON_CUSTOMERS_DIR —
|
|
173
|
+
// door with no CLEAROTRON_CUSTOMERS_DIR — the roster fallback. Compared as a set, not a count: a deployment may legitimately
|
|
174
174
|
// have three customers.
|
|
175
175
|
//
|
|
176
176
|
// DERIVED FROM THE DIRECTORY THAT SHIPS IT, never written down here. As a hand-maintained triple this
|
|
177
177
|
// went stale the day a fourth bundle landed: the set-equality could no longer match anything, so the
|
|
178
|
-
//
|
|
178
|
+
// roster-fallback detector returned PASS on the roster fallback itself — a guard that cannot match reports that it found
|
|
179
179
|
// nothing wrong. The two earlier faults on this same constant failed CLOSED and someone investigated;
|
|
180
180
|
// this one failed open. `generic` is excluded by the derivation, because `list_profiles` reports it as
|
|
181
181
|
// `genericFallback` rather than as a member of `clients[]`.
|
|
@@ -203,7 +203,7 @@ const skip = (n, d) => record(n, "skip", d); // could not be reached — never
|
|
|
203
203
|
* Two arms used to call `fail` while their own message said "This is a failure to look, never a pass".
|
|
204
204
|
* The text was honest and the verdict was not: FAIL is what a genuine drift also produces, so a reader
|
|
205
205
|
* could not tell "this box has drifted" from "I was unable to look" without reading to the end of the
|
|
206
|
-
* message. A drift is fixed by redeploying; a could
|
|
206
|
+
* message. A drift is fixed by redeploying; a check that could not look is fixed by pointing the check at something
|
|
207
207
|
* it can read. Until somebody does, nothing is known either way.
|
|
208
208
|
*
|
|
209
209
|
* IT IS NOT THE SAME AS AN ORDINARY SKIP, which is why this exists rather than reusing `skip`. Several
|
|
@@ -536,7 +536,7 @@ const wiredRegister = snapshot?.register?.provider ?? null;
|
|
|
536
536
|
if (wiredRegister) pass("register wired", `${wiredRegister} · canCount=${snapshot.register?.canCount === true}`);
|
|
537
537
|
else skip("register wired", "the snapshot carries no register block");
|
|
538
538
|
|
|
539
|
-
// 3a. The roster a door RESOLVES, vs the roster on disk.
|
|
539
|
+
// 3a. The roster a door RESOLVES, vs the roster on disk. The roster fallback: no CLEAROTRON_CUSTOMERS_DIR ⇒ the bundled demos
|
|
540
540
|
// ⇒ every real customer refused. Counts and set-identity only — never a customer name.
|
|
541
541
|
// Runs BEFORE the availability checks because plan_run needs a profile to plan against, and taking
|
|
542
542
|
// it from what the door itself resolved is what keeps a customer key out of this source file.
|
|
@@ -561,7 +561,7 @@ try {
|
|
|
561
561
|
// clients: 0 → an accounts-scoped session with NO accounts, i.e. THIS CALLER is unscoped.
|
|
562
562
|
// A statement about the probe, not the deployment. Never a failure.
|
|
563
563
|
// clients: <demos> → the door resolved the BUNDLED roster, i.e. CLEAROTRON_CUSTOMERS_DIR is not reaching
|
|
564
|
-
// the service. A statement about the deployment. This is
|
|
564
|
+
// the service. A statement about the deployment. This is the roster fallback.
|
|
565
565
|
// On the TEST instance the bundled roster is the CORRECT answer — CLEAROTRON_CUSTOMERS_DIR is deliberately
|
|
566
566
|
// unset there so real client bundles never reach it. Same observation, opposite verdict, so the
|
|
567
567
|
// instance has to say which it is rather than the check guessing from a hostname or a path.
|
|
@@ -570,10 +570,10 @@ try {
|
|
|
570
570
|
// protected; inside this top-level-await script neither could be caught by a test.
|
|
571
571
|
// The derivation THROWS rather than handing back an empty set, and the throw is caught here on its own
|
|
572
572
|
// so it cannot be reported as a list_profiles fault. Either way it is never a pass: an unreadable
|
|
573
|
-
// profiles directory means
|
|
573
|
+
// profiles directory means the roster fallback cannot be ruled out: the check could not look, and says so.
|
|
574
574
|
let bundledDemos = null;
|
|
575
575
|
try { bundledDemos = bundledDemoKeys({ profilesDir: BUNDLED_PROFILES_DIR }); }
|
|
576
|
-
catch (e) { fail("roster resolves", `the bundled roster could not be derived, so
|
|
576
|
+
catch (e) { fail("roster resolves", `the bundled roster could not be derived, so a door falling back to the bundled demo roster cannot be ruled out: ${e.message}`); }
|
|
577
577
|
|
|
578
578
|
// THE DOOR ANSWERED FOR THIS KEY, so the comparison is made within this key's cap. Its claims are read
|
|
579
579
|
// the way doctor and the brand-owner command read them. A key that cannot be read leaves the verdict
|
|
@@ -608,7 +608,7 @@ try {
|
|
|
608
608
|
}
|
|
609
609
|
|
|
610
610
|
// 3. THE LOAD-BEARING CHECK — every door's availability answer vs the engine's own, recomputed here
|
|
611
|
-
// from the snapshot. This is
|
|
611
|
+
// from the snapshot. This is the availability mismatch: the MCP disagreed with the portal because it derived availability
|
|
612
612
|
// from its own process environment instead of from this file.
|
|
613
613
|
let mcpOptions = null;
|
|
614
614
|
try {
|
|
@@ -731,7 +731,7 @@ if (mcpOptions && built) {
|
|
|
731
731
|
else pass("doors agree with the engine on product availability", `${seen.length} products, all matching: ${seen.join(", ")}`);
|
|
732
732
|
|
|
733
733
|
// 3b. describe_options and plan_run are two independent code paths (lib/options.mjs, lib/plan.mjs).
|
|
734
|
-
//
|
|
734
|
+
// The availability mismatch lived in both, so agreement here is necessary-but-not-sufficient — 3 above is the real test.
|
|
735
735
|
//
|
|
736
736
|
// plan_run needs an explicit profileKey: an accounts-scoped session refuses without one. The key
|
|
737
737
|
// comes from what the door ITSELF resolved (below), so no customer is ever hardcoded here.
|
|
@@ -863,7 +863,7 @@ else {
|
|
|
863
863
|
// to an unprobed posture — a failure to look, never a pass.
|
|
864
864
|
posture: { worker: probeWorker(), timer: probeTimer() },
|
|
865
865
|
});
|
|
866
|
-
// — AND THE VERDICT'S OWN
|
|
866
|
+
// — AND THE VERDICT'S OWN MARKER FOR A CHECK THAT COULD NOT LOOK, carried through. The verdict distinguishes a
|
|
867
867
|
// process table it could not read from one that answered; dropping that here would put the
|
|
868
868
|
// distinction back where it was, one layer down.
|
|
869
869
|
record("the process that executes runs is on the deployed commit", v.state, v.message, v.blocked === true);
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
// release-code-scanning-check.mjs — is the commit about to be published as a stable one code scanning
|
|
5
|
+
// has finished reading, with nothing left open?
|
|
6
|
+
//
|
|
7
|
+
// node scripts/release-code-scanning-check.mjs --sha <commit> --repo <owner/name>
|
|
8
|
+
// [--ref refs/heads/main] [--timeout 1200] [--interval 30]
|
|
9
|
+
//
|
|
10
|
+
// Exit 0: code scanning has a completed analysis of <sha> for every language it analyses on <ref>, and
|
|
11
|
+
// no alert on <ref> is open. Exit 1: alerts are open (each named), or the analysis of <sha> did not arrive
|
|
12
|
+
// within the bound. Exit 2: could not look — the API refused, or the arguments were incomplete.
|
|
13
|
+
//
|
|
14
|
+
// ── WHY A STABLE, AND ONLY A STABLE ─────────────────────────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// Code scanning was on and reported alerts, and an integration merge landed with its check red: a scanner
|
|
17
|
+
// that can stay red without stopping anything is a notification. The owner's ruling (2026-09-18): the
|
|
18
|
+
// check is required on main, and a stable is published only over a clean result. Betas are not gated —
|
|
19
|
+
// they are how a fix reaches the people testing it. There is NO OVERRIDE: an open alert is fixed, or
|
|
20
|
+
// dismissed on the alert with a written reason, and then this passes.
|
|
21
|
+
//
|
|
22
|
+
// ── IT WAITS, BECAUSE THE ANALYSIS FOLLOWS THE MERGE ────────────────────────────────────────────────
|
|
23
|
+
//
|
|
24
|
+
// The commit a stable publishes is the version commit, and the version pull request merges itself
|
|
25
|
+
// moments before this runs; code scanning analyses that push in a few minutes (measured on the beta.10
|
|
26
|
+
// release commit: under five). So an analysis not there YET is waited for, bounded; an analysis never
|
|
27
|
+
// there fails the publish, because a verdict nobody reached is not a clean one.
|
|
28
|
+
//
|
|
29
|
+
// ── WHY THE OPEN ALERTS ARE READ ON THE BRANCH ──────────────────────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// Alerts belong to a ref, not to a commit: the open list for main describes main's latest analysis. So
|
|
32
|
+
// the check first requires that analysis to be of <sha> itself — if main has moved past the commit being
|
|
33
|
+
// published, the list describes a different tree and this refuses rather than answer about it.
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
import { isEntrypoint } from "../shared/is-entrypoint.mjs";
|
|
36
|
+
|
|
37
|
+
const arg = (argv, flag, dflt = null) => { const i = argv.indexOf(flag); return i === -1 ? dflt : argv[i + 1]; };
|
|
38
|
+
const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
|
|
39
|
+
|
|
40
|
+
/** `gh api` as JSON, paginated where asked. Throws on a refusal, which the caller turns into exit 2. */
|
|
41
|
+
function gh(path, { paginate = false } = {}) {
|
|
42
|
+
// Paginated pages come back as one element per line (`--jq '.[]'`), so no page boundary is ever
|
|
43
|
+
// reassembled by editing text.
|
|
44
|
+
const args = ["api", ...(paginate ? ["--paginate", "--jq", ".[]"] : []), path];
|
|
45
|
+
const out = execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1 << 26 });
|
|
46
|
+
return paginate ? out.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l)) : JSON.parse(out);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PURE. The verdict from what the API said.
|
|
51
|
+
* @param {object} o
|
|
52
|
+
* @param {Array<{commit_sha:string, category?:string, error?:string, created_at?:string}>} o.analyses on <ref>, newest first
|
|
53
|
+
* @param {Array<{number:number, rule?:{id?:string}, most_recent_instance?:{location?:{path?:string, start_line?:number}}}>} o.open
|
|
54
|
+
*/
|
|
55
|
+
export function verdict({ sha, analyses, open }) {
|
|
56
|
+
const categories = [...new Set(analyses.map((a) => a.category ?? ""))];
|
|
57
|
+
if (!categories.length) return { state: "waiting", why: "code scanning has no analysis on this branch at all" };
|
|
58
|
+
const newest = new Map();
|
|
59
|
+
for (const a of analyses) if (!newest.has(a.category ?? "")) newest.set(a.category ?? "", a);
|
|
60
|
+
const notYet = categories.filter((c) => newest.get(c).commit_sha !== sha);
|
|
61
|
+
if (notYet.length) {
|
|
62
|
+
const moved = notYet.filter((c) => analyses.some((a) => (a.category ?? "") === c && a.commit_sha === sha));
|
|
63
|
+
if (moved.length) return { state: "moved", why: `the branch's newest ${moved.join(", ")} analysis is of another commit, so its alerts describe a different tree` };
|
|
64
|
+
return { state: "waiting", why: `no analysis of ${sha.slice(0, 8)} yet for ${notYet.join(", ")}` };
|
|
65
|
+
}
|
|
66
|
+
const failed = categories.filter((c) => newest.get(c).error);
|
|
67
|
+
if (failed.length) return { state: "failed", why: `the analysis of ${sha.slice(0, 8)} for ${failed.join(", ")} reported an error: ${newest.get(failed[0]).error}` };
|
|
68
|
+
if (open.length) return { state: "open", why: `${open.length} alert(s) are open`, open };
|
|
69
|
+
return { state: "clean", why: `${categories.length} analysis categor${categories.length === 1 ? "y" : "ies"} of ${sha.slice(0, 8)} complete, no alert open` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function main() {
|
|
73
|
+
const argv = process.argv.slice(2);
|
|
74
|
+
const sha = arg(argv, "--sha"), repo = arg(argv, "--repo");
|
|
75
|
+
const ref = arg(argv, "--ref", "refs/heads/main");
|
|
76
|
+
const timeoutSec = Number(arg(argv, "--timeout", "1200")), intervalSec = Number(arg(argv, "--interval", "30"));
|
|
77
|
+
if (!/^[0-9a-f]{40}$/.test(sha ?? "") || !/^[\w.-]+\/[\w.-]+$/.test(repo ?? "") || !(intervalSec > 0) || !Number.isFinite(timeoutSec)) {
|
|
78
|
+
console.error("release-code-scanning-check: needs --sha <40-character commit> and --repo <owner/name>. Could not look.");
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
82
|
+
for (;;) {
|
|
83
|
+
let v;
|
|
84
|
+
try {
|
|
85
|
+
const analyses = gh(`repos/${repo}/code-scanning/analyses?ref=${encodeURIComponent(ref)}&tool_name=CodeQL&per_page=100`);
|
|
86
|
+
const open = gh(`repos/${repo}/code-scanning/alerts?ref=${encodeURIComponent(ref)}&state=open&per_page=100`, { paginate: true });
|
|
87
|
+
v = verdict({ sha, analyses, open });
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.error(`release-code-scanning-check: the code-scanning API could not be read: ${String(e.stderr ?? e.message).split("\n")[0]}. `
|
|
90
|
+
+ "Could not look, which is not a clean scan.");
|
|
91
|
+
process.exit(2);
|
|
92
|
+
}
|
|
93
|
+
console.log(`release-code-scanning-check: at ${Math.floor(Date.now() / 1000)} — ${v.why}.`);
|
|
94
|
+
if (v.state === "clean") return;
|
|
95
|
+
if (v.state === "open") {
|
|
96
|
+
console.error(`\nA stable is not published over open code-scanning alerts. Fix each, or dismiss it on the alert with a written reason:`);
|
|
97
|
+
for (const a of v.open.slice(0, 25)) {
|
|
98
|
+
const l = a.most_recent_instance?.location ?? {};
|
|
99
|
+
console.error(` #${a.number} ${a.rule?.id ?? "?"} ${l.path ?? "?"}:${l.start_line ?? "?"}`);
|
|
100
|
+
}
|
|
101
|
+
if (v.open.length > 25) console.error(` … and ${v.open.length - 25} more`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
if (v.state === "moved" || v.state === "failed") { console.error(`\nrelease-code-scanning-check: ${v.why}. Not published.`); process.exit(1); }
|
|
105
|
+
if (Date.now() + intervalSec * 1000 > deadline) {
|
|
106
|
+
console.error(`\nrelease-code-scanning-check: after ${timeoutSec}s, ${v.why}. A verdict nobody reached is not a `
|
|
107
|
+
+ "clean one, so the stable is not published. Re-run the job once the analysis has finished.");
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
await sleep(intervalSec * 1000);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (isEntrypoint(import.meta.url)) main();
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
// release-visible-check.mjs — can a stranger's npm client get the version this run just published?
|
|
5
|
+
//
|
|
6
|
+
// node scripts/release-visible-check.mjs --version <v> --tag <dist-tag> --tarball <file>
|
|
7
|
+
// [--timeout 900] [--interval 15] [--registry https://registry.npmjs.org] [--name clearotron]
|
|
8
|
+
//
|
|
9
|
+
// Exit 0: the registry serves <v>, the <dist-tag> names it, and the tarball it serves is byte for byte
|
|
10
|
+
// the one at <file> — the bytes this run published. Exit 1: not within the bound, or the registry serves
|
|
11
|
+
// different bytes; the caller creates no release entry. Exit 2: could not look — bad arguments, or the
|
|
12
|
+
// local tarball cannot be read.
|
|
13
|
+
//
|
|
14
|
+
// ── WHY THIS RUNS BEFORE THE RELEASE ENTRY ─────────────────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// `npm publish` returning is the registry accepting the upload, not the registry serving it. Measured on
|
|
17
|
+
// 0.3.2-beta.10, 2026-09-18: the release entry went up at 19:47:40Z and the workflow finished three
|
|
18
|
+
// seconds later, while an unauthenticated `npm view clearotron@0.3.2-beta.10` answered E404 and the beta
|
|
19
|
+
// tag still named beta.9 until about 19:56Z. Nine minutes in which the releases page named a version
|
|
20
|
+
// nobody could install, and a green run reported it done on evidence no user could see. So the entry
|
|
21
|
+
// waits for the registry to answer a stranger, and a registry that never does leaves no entry at all.
|
|
22
|
+
//
|
|
23
|
+
// ── UNAUTHENTICATED ON PURPOSE ──────────────────────────────────────────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// Plain requests with no credential and no npm configuration. The publishing job holds a publish
|
|
26
|
+
// credential, and a read made with it can see what the public cannot yet — which is the gap this
|
|
27
|
+
// exists to close. No cache-busting query either: a stranger's client does not add one.
|
|
28
|
+
//
|
|
29
|
+
// ── AND THE BYTES, NOT ONLY THE NAME ────────────────────────────────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// A version document can answer while the tarball behind it is not the one this run built. So the
|
|
32
|
+
// tarball the registry serves is fetched, as `npm pack <name>@<version>` would fetch it, and its sha512
|
|
33
|
+
// is compared both with the registry's own recorded integrity and with the local file this run
|
|
34
|
+
// uploaded. Either disagreeing is a refusal, not a warning.
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import { isEntrypoint } from "../shared/is-entrypoint.mjs";
|
|
38
|
+
|
|
39
|
+
const arg = (argv, flag, dflt = null) => { const i = argv.indexOf(flag); return i === -1 ? dflt : argv[i + 1]; };
|
|
40
|
+
const sri = (bytes) => `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
|
|
41
|
+
const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
|
|
42
|
+
|
|
43
|
+
/** One look at the registry, as a stranger. Returns what it served and nothing it did not. */
|
|
44
|
+
export async function lookOnce({ registry, name, version, tag }) {
|
|
45
|
+
const seen = { version: null, tagged: null, integrity: null, tarball: null, error: null };
|
|
46
|
+
try {
|
|
47
|
+
const v = await fetch(`${registry}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { headers: { accept: "application/json" } });
|
|
48
|
+
if (v.ok) {
|
|
49
|
+
const doc = await v.json();
|
|
50
|
+
seen.version = doc.version ?? null;
|
|
51
|
+
seen.integrity = doc.dist?.integrity ?? null;
|
|
52
|
+
seen.tarball = doc.dist?.tarball ?? null;
|
|
53
|
+
}
|
|
54
|
+
const t = await fetch(`${registry}/-/package/${encodeURIComponent(name)}/dist-tags`, { headers: { accept: "application/json" } });
|
|
55
|
+
if (t.ok) seen.tagged = (await t.json())?.[tag] ?? null;
|
|
56
|
+
} catch (e) { seen.error = String(e?.message ?? e); }
|
|
57
|
+
return seen;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** PURE. Is what a look saw the published version, under its tag? */
|
|
61
|
+
export function visible(seen, { version }) {
|
|
62
|
+
return seen.version === version && seen.tagged === version && typeof seen.tarball === "string";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function main() {
|
|
66
|
+
const argv = process.argv.slice(2);
|
|
67
|
+
const version = arg(argv, "--version"), tag = arg(argv, "--tag"), tarballPath = arg(argv, "--tarball");
|
|
68
|
+
const registry = String(arg(argv, "--registry", "https://registry.npmjs.org")).replace(/\/+$/, "");
|
|
69
|
+
const name = arg(argv, "--name", "clearotron");
|
|
70
|
+
const timeoutSec = Number(arg(argv, "--timeout", "900")), intervalSec = Number(arg(argv, "--interval", "15"));
|
|
71
|
+
if (!version || !tag || !tarballPath || !Number.isFinite(timeoutSec) || !Number.isFinite(intervalSec) || intervalSec <= 0) {
|
|
72
|
+
console.error("release-visible-check: needs --version, --tag and --tarball, and a positive --interval. Could not look.");
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
75
|
+
let local;
|
|
76
|
+
try { local = sri(readFileSync(tarballPath)); }
|
|
77
|
+
catch (e) { console.error(`release-visible-check: cannot read the published tarball ${tarballPath}: ${e.message}. Could not look.`); process.exit(2); }
|
|
78
|
+
|
|
79
|
+
const started = Date.now(), deadline = started + timeoutSec * 1000;
|
|
80
|
+
const epoch = () => Math.floor(Date.now() / 1000);
|
|
81
|
+
let seen;
|
|
82
|
+
for (;;) {
|
|
83
|
+
seen = await lookOnce({ registry, name, version, tag });
|
|
84
|
+
const waited = Math.round((Date.now() - started) / 1000);
|
|
85
|
+
console.log(`release-visible-check: at ${epoch()} (+${waited}s) the registry answers ${name}@${version} → `
|
|
86
|
+
+ `${seen.version ?? "nothing"}; ${tag} → ${seen.tagged ?? "nothing"}${seen.error ? ` (${seen.error})` : ""}`);
|
|
87
|
+
if (visible(seen, { version })) break;
|
|
88
|
+
if (Date.now() + intervalSec * 1000 > deadline) {
|
|
89
|
+
console.error(`\nrelease-visible-check: after ${waited}s an unauthenticated client still cannot get ${name}@${version} `
|
|
90
|
+
+ `under \`${tag}\`. The publish was accepted and is not yet served, so no release entry is created: `
|
|
91
|
+
+ "the page would name a version nobody can install. The tag is already written, so the pipeline "
|
|
92
|
+
+ "knows this version is published; re-run this job to create the entry once the registry serves it.");
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
await sleep(intervalSec * 1000);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let served;
|
|
99
|
+
try {
|
|
100
|
+
const r = await fetch(seen.tarball);
|
|
101
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
102
|
+
served = sri(Buffer.from(await r.arrayBuffer()));
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error(`release-visible-check: the registry names ${seen.tarball} and it could not be fetched: ${e.message}. No release entry.`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
if (served !== local || (seen.integrity && seen.integrity !== local)) {
|
|
108
|
+
console.error(`release-visible-check: the registry serves DIFFERENT BYTES for ${name}@${version}.\n`
|
|
109
|
+
+ ` published by this run: ${local}\n served: ${served}\n recorded integrity: ${seen.integrity ?? "none"}\n`
|
|
110
|
+
+ "No release entry is created for a version whose install is not the build this run verified.");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
console.log(`release-visible-check: a stranger can install ${name}@${version} under \`${tag}\`, `
|
|
114
|
+
+ `and the tarball served is the one this run published (${served.slice(0, 22)}…).`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (isEntrypoint(import.meta.url)) main();
|
package/scripts/repo-writes.mjs
CHANGED
|
@@ -119,3 +119,45 @@ export function explainRepoWrites(rows) {
|
|
|
119
119
|
" touching the tree to tell the two apart.)",
|
|
120
120
|
];
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
// ── the home the run executes as ────────────────────────────────────────────────────────────────────
|
|
124
|
+
//
|
|
125
|
+
// THE SAME RULE, ONE FOLDER OUT. The suite is documented as offline with no side effects, and a fresh
|
|
126
|
+
// clone's run left `~/trademark/telemetry/trademark-mcp-access.jsonl` behind (measured 2026-09-19, one
|
|
127
|
+
// line per run): a test spawned the stdio server with the real home, and `doctor` on that machine then
|
|
128
|
+
// reported the client door's access log as being written. These are the product's own folders under a
|
|
129
|
+
// home: the pool, workspace, queue and telemetry, and the settings, records and revocation list. A run
|
|
130
|
+
// leaves them as it found them. The rest of a home — npm's cache, a browser's profile — belongs to the
|
|
131
|
+
// tools a run uses, and is theirs to write.
|
|
132
|
+
|
|
133
|
+
/** The product's own folders under a home, relative to it. */
|
|
134
|
+
export const HOME_DATA = Object.freeze(["trademark", join(".config", "clearotron")]);
|
|
135
|
+
|
|
136
|
+
/** Every path under the home's product folders, stamped. A folder that does not exist is not walked. */
|
|
137
|
+
export function snapshotHome(home) {
|
|
138
|
+
const out = new Map();
|
|
139
|
+
for (const rel of HOME_DATA) {
|
|
140
|
+
const p = join(resolve(home), rel);
|
|
141
|
+
let isDir = false;
|
|
142
|
+
try { isDir = lstatSync(p).isDirectory(); } catch { continue; }
|
|
143
|
+
if (!isDir) { out.set(p, "not-a-directory"); continue; }
|
|
144
|
+
out.set(p, "dir");
|
|
145
|
+
for (const [k, v] of snapshotRepo(p)) out.set(k, v);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What a reader is told when a run wrote under the home's product folders. */
|
|
151
|
+
export function explainHomeWrites(rows, home) {
|
|
152
|
+
return [
|
|
153
|
+
"",
|
|
154
|
+
`[test-run] THIS RUN WROTE UNDER THE HOME IT RAN AS (${home}), in the product's own folders:`,
|
|
155
|
+
...rows,
|
|
156
|
+
"",
|
|
157
|
+
" A test that starts a product command hands it a HOME under its own temp directory. Without one, the",
|
|
158
|
+
" command reads and writes the pool, queue, telemetry and settings of whoever runs the suite.",
|
|
159
|
+
"",
|
|
160
|
+
" (A service of this account writing there while the run was in flight prints this too. Run again",
|
|
161
|
+
" with HOME set to an empty temp directory to tell the two apart.)",
|
|
162
|
+
];
|
|
163
|
+
}
|