clearotron 0.3.3 → 0.4.0-beta.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 +9 -0
- package/INSTALL.md +1 -14
- package/bin/brandowner.mjs +5 -5
- package/bin/connect.mjs +4 -4
- package/bin/onboard.mjs +5 -7
- package/bin/start.mjs +20 -5
- package/bin/update.mjs +6 -1
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +2 -6
- package/driver/CHANGELOG.md +28 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/band-shape.mjs +8 -8
- package/driver/blind-frame-model.mjs +1 -1
- package/driver/common-law-receipts.mjs +2 -2
- package/driver/commonlaw-carry.mjs +2 -2
- package/driver/company-bundle.mjs +11 -18
- package/driver/connotation-search.mjs +4 -4
- package/driver/contract-e3-backlog.mjs +3 -3
- package/driver/declination-call.mjs +1 -1
- package/driver/declination-tool.mjs +1 -1
- package/driver/dev-portal.mjs +1 -1
- package/driver/door-call-verdict.mjs +27 -0
- package/driver/driver.config.mjs +17 -11
- package/driver/e2e/README.md +1 -1
- package/driver/engine/mcp/clarivate-server.mjs +2 -1
- package/driver/engine/mcp/corsearch-server.mjs +1 -0
- package/driver/engine/mcp/euipo-server.mjs +1 -0
- package/driver/engine/mcp/free-tier-server.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/engine/mcp/perplexity-server.mjs +1 -1
- package/driver/engine/mcp/recording-server.mjs +1 -1
- package/driver/engine/mcp/signa-server.mjs +1 -0
- package/driver/engine/mcp/supplemental.mjs +1 -1
- package/driver/engine/mcp/uspto-local-server.mjs +1 -0
- package/driver/enqueue-schema.mjs +2 -2
- package/driver/feedback-issues.mjs +1 -1
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +3 -3
- package/driver/flag-snapshot.mjs +1 -1
- package/driver/floor-duty.mjs +2 -2
- package/driver/form-neighbourhood.mjs +47 -15
- package/driver/frame-diff-model.mjs +3 -3
- package/driver/gateway.mjs +5 -5
- package/driver/jx-lanes.mjs +1 -1
- package/driver/known-conflicts.mjs +18 -0
- package/driver/log.mjs +2 -2
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +129 -95
- package/driver/pipeline.mjs +69 -32
- package/driver/placement-carry.mjs +2 -2
- package/driver/placement-form.mjs +1 -1
- package/driver/portal-mcp-client.mjs +1 -1
- package/driver/portal-request-origin.mjs +79 -0
- package/driver/portal-service.mjs +45 -17
- package/driver/predelivery-lint.mjs +10 -10
- package/driver/profile-page.html +9 -13
- package/driver/profile-service.mjs +25 -13
- package/driver/profiles.mjs +17 -4
- package/driver/progress.mjs +1 -1
- package/driver/provider-usage.mjs +24 -1
- package/driver/publish/index.mjs +17 -5
- package/driver/publish/knockout.mjs +3 -2
- package/driver/publish/render-knockout.mjs +1 -1
- package/driver/publish/render.mjs +14 -3
- package/driver/publish/search-depth.mjs +4 -2
- package/driver/recall-reconciliation.mjs +1 -1
- package/driver/record-carry.mjs +6 -6
- package/driver/recording-agreement.mjs +2 -2
- package/driver/reference-score.mjs +27 -27
- package/driver/register-count.mjs +56 -1
- package/driver/register-digest-record.mjs +1 -1
- package/driver/register-plan.mjs +5 -5
- package/driver/register-records.mjs +10 -1
- package/driver/registry-fidelity.mjs +4 -4
- package/driver/repair-composers.mjs +6 -6
- package/driver/run-economics.mjs +8 -19
- package/driver/screen-gate.mjs +1 -1
- package/driver/skills/blind-frame/SKILL.md +2 -2
- package/driver/skills/clearance-common-law/SKILL.md +2 -2
- package/driver/skills/clearance-common-law/perplexity-prompts.md +4 -4
- package/driver/skills/clearance-register/digest.md +1 -1
- package/driver/skills/clearance-register/unit.md +1 -1
- package/driver/skills/clearance-search/report-prose.md +5 -5
- package/driver/skills/clearance-search/synthesis-rules.md +4 -4
- package/driver/skills/clearance-variants/SKILL.md +2 -2
- package/driver/skills/clearance-variants/transliteration-scripts.md +1 -1
- package/driver/skills/frame-diff/SKILL.md +2 -2
- package/driver/skills/knockout-assess/SKILL.md +9 -9
- package/driver/skills/matter-frame/watchlist-reference.md +1 -1
- package/driver/skills/narrative-refutation/SKILL.md +2 -2
- package/driver/skills/placement-inquiry/SKILL.md +1 -1
- package/driver/stage-context.mjs +4 -4
- package/driver/stages.mjs +23 -15
- package/driver/suite-census.json +138 -42
- package/driver/systemd/clearotron-client-mcp.service +24 -0
- package/driver/systemd/clearotron-mcp-face.service +24 -0
- package/driver/systemd/clearotron-portal.service +24 -0
- package/driver/systemd/clearotron-worker.service +24 -0
- package/driver/tokens.mjs +26 -17
- package/driver/turnaround-bands.mjs +1 -1
- package/driver/unit-inventory.mjs +3 -3
- package/driver/variant-manifest-model.mjs +1 -1
- package/driver/verify.mjs +2 -2
- package/driver/whatif-memo-run.mjs +1 -1
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/audit.mjs +9 -2
- package/mcp-server/lib/http-handler.mjs +7 -3
- package/mcp-server/mint-token.mjs +8 -6
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +11 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-D_O_55vK.js} +59 -9
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/_shared/README.md +1 -1
- package/providers/_shared/answer-memory.mjs +199 -0
- package/providers/_shared/ledger-path.mjs +1 -1
- package/providers/_shared/ledger.mjs +47 -5
- package/providers/_shared/script-form.mjs +24 -5
- package/providers/_shared/term-shape.mjs +5 -5
- package/providers/clarivate/src/capabilities.js +11 -0
- package/providers/clarivate/src/core.js +140 -13
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/capabilities.js +24 -0
- package/providers/signa/src/core.js +66 -0
- package/scripts/deprecate-below.mjs +114 -2
- package/scripts/freeze-example-run.mjs +1 -1
- package/scripts/live-surface-check.mjs +11 -2
- package/scripts/release-entry-catch-up.mjs +211 -0
- package/scripts/release-note-required.mjs +102 -6
- package/scripts/release-rehearsal-version.mjs +60 -0
- package/scripts/release-sbom.mjs +104 -0
- package/scripts/release-visible-check.mjs +7 -5
- package/scripts/score.mjs +3 -3
- package/shared/brand.mjs +1 -1
- package/shared/client-door.mjs +15 -8
- package/shared/driver-dir.mjs +20 -9
- package/shared/names-in-force.mjs +2 -0
- package/shared/scope.mjs +25 -10
- package/shared/store-in-repo.mjs +38 -17
|
@@ -0,0 +1,211 @@
|
|
|
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-entry-catch-up.mjs — finish a release npm was still validating when the publish job stopped waiting.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/release-entry-catch-up.mjs [--repo owner/name] [--bound-hours 6] [--dry-run]
|
|
7
|
+
//
|
|
8
|
+
// WHY THIS EXISTS. npm now accepts a publish before it serves it ("Your package is being processed and may
|
|
9
|
+
// take a few minutes to become available"), and the processing is not bounded. The publish job waits for
|
|
10
|
+
// an unauthenticated client to get the exact bytes it uploaded before it creates the release entry, and
|
|
11
|
+
// that wait has to end somewhere. Measured 2026-09-23: 0.3.3-beta.1 was served eleven minutes after npm
|
|
12
|
+
// accepted it; the 0.3.3 stable was still "Validating" on npmjs.com more than fifty minutes after, the
|
|
13
|
+
// job gave up at its bound, and no entry was ever created. GitHub went on naming 0.3.2 as the latest
|
|
14
|
+
// release while the stable was already published. Nothing ran again to finish it.
|
|
15
|
+
//
|
|
16
|
+
// So a tagged version with no entry is PENDING, not failed, and the scheduled run finishes it: once the
|
|
17
|
+
// registry serves the bytes the publish job kept, the entry is created from the changelog, exactly as the
|
|
18
|
+
// publish job would have created it. Past a bound with the version still not served, it says so in red.
|
|
19
|
+
//
|
|
20
|
+
// THE SAME CHECK, ON THE SAME BYTES. The publish job keeps the tarball it uploaded as an artifact named
|
|
21
|
+
// `published-<version>`, and this hands that file to release-visible-check.mjs, so an entry is only ever
|
|
22
|
+
// created over bytes the registry serves and this pipeline published. A tagged version with no kept bytes
|
|
23
|
+
// (one published before this existed) is reported, never guessed at.
|
|
24
|
+
//
|
|
25
|
+
// THE RELEASE RUN'S ARTIFACT, NEVER ANY ARTIFACT OF THAT NAME. Any run in the repository can upload an
|
|
26
|
+
// artifact called `published-<version>`, a fork's pull request included, and GitHub lists same-named
|
|
27
|
+
// artifacts highest id first, so a name alone takes the last upload: its parts list would ride on the
|
|
28
|
+
// official entry, and its tarball would report a sound release as different bytes. An artifact is used only
|
|
29
|
+
// when the run that made it is this repository's release workflow, on `main`, at the tagged commit or
|
|
30
|
+
// behind it. Behind it is the ordinary case for a cut: its publish runs on the commit it was dispatched
|
|
31
|
+
// from, and the version commit it tags lands on `main` after (0.3.3-beta.1: run on `1d7722ac`, tag on
|
|
32
|
+
// `d571d0ee`).
|
|
33
|
+
//
|
|
34
|
+
// Exit 0: nothing to do, the entry was created, or the version is pending inside the bound.
|
|
35
|
+
// Exit 1: the registry serves different bytes, the version is still not served past the bound, or its bytes
|
|
36
|
+
// cannot be found to check against.
|
|
37
|
+
// Exit 2: could not look.
|
|
38
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
39
|
+
import { mkdtempSync, readFileSync, readdirSync, writeFileSync, rmSync } from "node:fs";
|
|
40
|
+
import { tmpdir } from "node:os";
|
|
41
|
+
import { join, dirname } from "node:path";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
43
|
+
import { isEntrypoint } from "../shared/is-entrypoint.mjs";
|
|
44
|
+
|
|
45
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What to do about the version at main's head. PURE, so every branch is driven without a registry.
|
|
49
|
+
* @param {{ tagged: boolean, entryExists: boolean, keptBytes: boolean, visibleExit: number|null,
|
|
50
|
+
* ageSec: number|null, boundSec: number }} s
|
|
51
|
+
* @returns {{ action: "none"|"create"|"wait"|"report", exit: 0|1|2, why: string }}
|
|
52
|
+
*/
|
|
53
|
+
export function decide(s) {
|
|
54
|
+
if (!s.tagged) return { action: "none", exit: 0, why: "not tagged: nothing has been published to finish" };
|
|
55
|
+
if (s.entryExists) return { action: "none", exit: 0, why: "the release entry already exists" };
|
|
56
|
+
if (!s.keptBytes) {
|
|
57
|
+
return { action: "report", exit: 1,
|
|
58
|
+
why: "tagged with no release entry, and no kept bytes to check the registry against: create the entry by hand after checking what the registry serves" };
|
|
59
|
+
}
|
|
60
|
+
if (s.visibleExit === 0) return { action: "create", exit: 0, why: "the registry serves the published bytes" };
|
|
61
|
+
// DIFFERENT BYTES ARE NEVER PENDING. The registry serves this version and it is not the build that was
|
|
62
|
+
// published, so it is red at once, however young the tag.
|
|
63
|
+
if (s.visibleExit === 3) {
|
|
64
|
+
return { action: "report", exit: 1, why: "the registry serves DIFFERENT BYTES for this version than the publish job kept" };
|
|
65
|
+
}
|
|
66
|
+
if (s.visibleExit === 1) {
|
|
67
|
+
if (s.ageSec != null && s.ageSec > s.boundSec) {
|
|
68
|
+
return { action: "report", exit: 1,
|
|
69
|
+
why: `tagged ${Math.round(s.ageSec / 3600)} h ago and the registry still does not serve these bytes` };
|
|
70
|
+
}
|
|
71
|
+
return { action: "wait", exit: 0, why: "npm has not served it yet; the next scheduled run looks again" };
|
|
72
|
+
}
|
|
73
|
+
return { action: "report", exit: 2, why: "the registry check could not look" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const RELEASE_WORKFLOW = ".github/workflows/release.yml";
|
|
77
|
+
const RELEASE_EVENTS = new Set(["push", "workflow_dispatch", "schedule"]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Why a kept artifact is NOT the release run's own, or null when it is. PURE. Called first with the
|
|
81
|
+
* artifact alone, which reads only what the artifact list already says, so a fork's upload costs no further
|
|
82
|
+
* call; then with the run that made it and how that run's commit relates to the tagged one (the compare
|
|
83
|
+
* API's `status` from the run's commit to the tag: "identical" or "ahead" means at the tag or behind it).
|
|
84
|
+
* @param {{ artifact: object, repoId: number, run?: object, relation?: string }} s
|
|
85
|
+
* @returns {string|null}
|
|
86
|
+
*/
|
|
87
|
+
export function notTheReleaseRun({ artifact, repoId, run, relation }) {
|
|
88
|
+
const w = artifact.workflow_run ?? {};
|
|
89
|
+
if (w.repository_id !== repoId || w.head_repository_id !== repoId) return "it was made by a run from another repository, such as a fork's pull request";
|
|
90
|
+
if (w.head_branch !== "main") return `it was made by a run on ${w.head_branch ? `\`${w.head_branch}\`` : "no branch"}, not on main`;
|
|
91
|
+
if (run === undefined) return null;
|
|
92
|
+
if (run.id !== w.id) return "the run read back is not the run the artifact names";
|
|
93
|
+
if (run.path !== RELEASE_WORKFLOW) return `it was made by ${run.path ?? "an unnamed workflow"}, not the release workflow`;
|
|
94
|
+
if (!RELEASE_EVENTS.has(run.event)) return `it was made by a ${run.event ?? "nameless"} run, which never publishes`;
|
|
95
|
+
if (run.head_repository?.id !== repoId || run.head_branch !== "main") return "the run that made it is not on this repository's main";
|
|
96
|
+
if (relation !== "identical" && relation !== "ahead") return `it was made on a commit that is not the tagged commit or behind it (${relation ?? "unknown"})`;
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const arg = (argv, flag, dflt = null) => { const i = argv.indexOf(flag); return i === -1 ? dflt : argv[i + 1]; };
|
|
101
|
+
const gh = (args, opts = {}) => execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts });
|
|
102
|
+
|
|
103
|
+
// Whether the entry exists. Only GitHub's own "release not found" is a no; any other failure throws.
|
|
104
|
+
function entryFor(tag, repo) {
|
|
105
|
+
const r = spawnSync("gh", ["release", "view", tag, "--repo", repo, "--json", "tagName"], { encoding: "utf8" });
|
|
106
|
+
if (r.status === 0) return true;
|
|
107
|
+
if (/release not found/i.test(r.stderr ?? "")) return false;
|
|
108
|
+
throw Object.assign(new Error(`could not read the release entry for ${tag}`), { stderr: r.stderr || r.error?.message });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The first unexpired `published-<version>` artifact the release run made, or null. Every other one is
|
|
112
|
+
// named in a warning with the reason it was refused. EVERY PAGE OF THE LIST IS READ, NOT THE FIRST: any
|
|
113
|
+
// run can upload an artifact of that name, so a hundred uploads made after the release run's would push
|
|
114
|
+
// its own off a single page, and the version would be reported as having no kept bytes.
|
|
115
|
+
function keptArtifact(repo, version, tagSha) {
|
|
116
|
+
const repoId = Number(gh(["api", `repos/${repo}`, "--jq", ".id"]).trim());
|
|
117
|
+
for (let page = 1, seen = 0, total = 1; seen < total; page++) {
|
|
118
|
+
const list = JSON.parse(gh(["api", `repos/${repo}/actions/artifacts?name=published-${version}&per_page=100&page=${page}`]));
|
|
119
|
+
const artifacts = list.artifacts ?? [];
|
|
120
|
+
if (!artifacts.length) break; // the count named more than the pages hold: nothing further to read
|
|
121
|
+
total = list.total_count ?? 0;
|
|
122
|
+
seen += artifacts.length;
|
|
123
|
+
for (const artifact of artifacts.filter((x) => !x.expired && x.name === `published-${version}`)) {
|
|
124
|
+
let why = notTheReleaseRun({ artifact, repoId });
|
|
125
|
+
if (!why) {
|
|
126
|
+
const run = JSON.parse(gh(["api", `repos/${repo}/actions/runs/${artifact.workflow_run.id}`]));
|
|
127
|
+
const relation = gh(["api", `repos/${repo}/compare/${run.head_sha}...${tagSha}`, "--jq", ".status"]).trim();
|
|
128
|
+
why = notTheReleaseRun({ artifact, repoId, run, relation });
|
|
129
|
+
}
|
|
130
|
+
if (!why) return artifact;
|
|
131
|
+
console.log(`::warning::release-entry-catch-up: refused artifact ${artifact.id} named published-${version}: ${why}.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function main() {
|
|
138
|
+
try { return catchUp(process.argv.slice(2)); }
|
|
139
|
+
catch (e) {
|
|
140
|
+
// A GitHub call that failed is a check that could not look, never the verdict "past the bound".
|
|
141
|
+
console.log(`::error::release-entry-catch-up could not finish: ${String(e.stderr || e.message).trim().split("\n")[0]}`);
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function catchUp(argv) {
|
|
147
|
+
const repo = arg(argv, "--repo", process.env.GITHUB_REPOSITORY);
|
|
148
|
+
const boundSec = Number(arg(argv, "--bound-hours", "6")) * 3600;
|
|
149
|
+
const dry = argv.includes("--dry-run");
|
|
150
|
+
if (!repo || !(boundSec > 0)) { console.error("release-entry-catch-up: needs --repo (or GITHUB_REPOSITORY) and a positive --bound-hours. Could not look."); return 2; }
|
|
151
|
+
|
|
152
|
+
const version = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
|
|
153
|
+
const tag = `v${version}`;
|
|
154
|
+
const prerelease = version.includes("-");
|
|
155
|
+
|
|
156
|
+
// THE EXACT REF, as the publish job asks it: a prefix read would take v0.3.1 for v0.3.1-beta.0.
|
|
157
|
+
let tagged;
|
|
158
|
+
try { tagged = gh(["api", `repos/${repo}/git/ref/tags/${tag}`, "--jq", ".ref"]).trim() === `refs/tags/${tag}`; }
|
|
159
|
+
catch (e) { if (/HTTP 404/.test(String(e.stderr))) tagged = false; else { console.error(`release-entry-catch-up: could not read the tag ${tag}: ${String(e.stderr || e.message).trim()}`); return 2; } }
|
|
160
|
+
const entryExists = tagged ? entryFor(tag, repo) : false;
|
|
161
|
+
|
|
162
|
+
let keptBytes = false, visibleExit = null, ageSec = null, work = null, sbom = null;
|
|
163
|
+
if (tagged && !entryExists) {
|
|
164
|
+
const [tagSha, when] = gh(["api", `repos/${repo}/commits/${tag}`, "--jq", `.sha + " " + .commit.committer.date`]).trim().split(" ");
|
|
165
|
+
ageSec = Math.max(0, (Date.now() - Date.parse(when)) / 1000);
|
|
166
|
+
const a = keptArtifact(repo, version, tagSha);
|
|
167
|
+
if (a) {
|
|
168
|
+
work = mkdtempSync(join(tmpdir(), "entry-catch-up-"));
|
|
169
|
+
writeFileSync(join(work, "kept.zip"), execFileSync("gh", ["api", `repos/${repo}/actions/artifacts/${a.id}/zip`],
|
|
170
|
+
{ maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }));
|
|
171
|
+
execFileSync("unzip", ["-q", "-o", join(work, "kept.zip"), "-d", join(work, "kept")]);
|
|
172
|
+
const kept = readdirSync(join(work, "kept"));
|
|
173
|
+
const tgz = kept.find((f) => f.endsWith(".tgz"));
|
|
174
|
+
const cdx = kept.find((f) => f.endsWith(".cdx.json"));
|
|
175
|
+
if (cdx) sbom = join(work, "kept", cdx);
|
|
176
|
+
if (tgz) {
|
|
177
|
+
keptBytes = true;
|
|
178
|
+
const r = spawnSync(process.execPath, [join(ROOT, "scripts", "release-visible-check.mjs"), "--version", version,
|
|
179
|
+
"--tag", prerelease ? "beta" : "latest", "--tarball", join(work, "kept", tgz), "--timeout", "60", "--interval", "15"],
|
|
180
|
+
{ encoding: "utf8" });
|
|
181
|
+
process.stdout.write(r.stdout ?? ""); process.stderr.write(r.stderr ?? "");
|
|
182
|
+
visibleExit = r.status;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const d = decide({ tagged, entryExists, keptBytes, visibleExit, ageSec, boundSec });
|
|
188
|
+
console.log(`release-entry-catch-up: ${tag}: ${d.why}.`);
|
|
189
|
+
if (d.action === "create") {
|
|
190
|
+
if (dry) { console.log(`release-entry-catch-up: --dry-run, so no entry is created for ${tag}.`); }
|
|
191
|
+
else {
|
|
192
|
+
// THE SAME ENTRY THE PUBLISH JOB WRITES: the changelog's section for this version, flagged as a
|
|
193
|
+
// pre-release for a beta, and GitHub's own notes only when the changelog has no section.
|
|
194
|
+
const notes = spawnSync(process.execPath, [join(ROOT, "scripts", "release-notes-for.mjs"), version], { encoding: "utf8" }).stdout ?? "";
|
|
195
|
+
// The parts list the publish job kept beside the bytes rides as an asset, as it does on an entry the
|
|
196
|
+
// publish job writes itself. A version kept before the list existed has none, and gets none.
|
|
197
|
+
const args = ["release", "create", tag, ...(sbom ? [sbom] : []), "--repo", repo, "--verify-tag", "--title", tag];
|
|
198
|
+
if (notes.trim()) { const f = join(work ?? mkdtempSync(join(tmpdir(), "entry-notes-")), "notes.md"); writeFileSync(f, notes); args.push("--notes-file", f); }
|
|
199
|
+
else args.push("--generate-notes");
|
|
200
|
+
if (prerelease) args.push("--prerelease");
|
|
201
|
+
gh(args);
|
|
202
|
+
console.log(`release-entry-catch-up: created the release entry for ${tag}${prerelease ? " as a pre-release" : ""}.`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (d.exit === 1) console.log(`::error::${tag}: ${d.why}.`);
|
|
206
|
+
else if (d.action === "wait") console.log(`::notice::${tag}: ${d.why}.`);
|
|
207
|
+
if (work) rmSync(work, { recursive: true, force: true });
|
|
208
|
+
return d.exit;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (isEntrypoint(import.meta.url)) process.exitCode = main();
|
|
@@ -101,6 +101,27 @@
|
|
|
101
101
|
// A deletion that gives no reason still refuses, and names the deleting commit as where the reason goes.
|
|
102
102
|
// The move a pre-release makes — `.changeset/<name>.md` deleted and `.changeset/pre/<name>.md` added in
|
|
103
103
|
// one commit — is not a withdrawal: the note still reaches a reader, and a version commit is that shape.
|
|
104
|
+
// ── A LATER COMMIT MAY ANSWER A BARE `none` FOR ONE NAMED COMMIT ───────────────────────────────────────
|
|
105
|
+
//
|
|
106
|
+
// A commit carried `Release-note: none` with no reason and was merged into an integration branch before
|
|
107
|
+
// anyone read it, and a second branch was already built on that merge. The only fix this check offered was
|
|
108
|
+
// the commit's own message, so the answer was a rewrite of a shared branch. A later commit may therefore
|
|
109
|
+
// answer for it, in its own message:
|
|
110
|
+
//
|
|
111
|
+
// Release-note-for: <sha> none — <why a reader would see no difference>
|
|
112
|
+
//
|
|
113
|
+
// Held tight, because a loose one excuses by accident:
|
|
114
|
+
// - one line names one commit, by its full sha or a prefix of at least 7 characters, resolved against
|
|
115
|
+
// THIS range's commits only. A prefix that matches none of them, or several, is refused;
|
|
116
|
+
// - the named commit must be an ANCESTOR of the answering one (git ancestry, never timestamps), and a
|
|
117
|
+
// commit cannot answer for itself;
|
|
118
|
+
// - the reason is read by NO_NOTE itself, so the two forms cannot drift. Empty, or a dash, is refused;
|
|
119
|
+
// - one commit takes one answer: two lines naming the same commit are both refused, even when they agree;
|
|
120
|
+
// - an answer naming a commit that owes none (it ships no code, carries a note, or gives its own reason)
|
|
121
|
+
// is refused as stale. An answer ignored in silence reads as though it did something.
|
|
122
|
+
// Every answer taken is printed with the sha that gave it, and every refused answer fails the check and
|
|
123
|
+
// names the commit that carries it. The answering commit is read like any other: one that ships no code,
|
|
124
|
+
// such as an empty commit, owes nothing of its own.
|
|
104
125
|
import { execFileSync } from "node:child_process";
|
|
105
126
|
import { readFileSync } from "node:fs";
|
|
106
127
|
import { join, dirname } from "node:path";
|
|
@@ -163,6 +184,51 @@ export const NEVER_A_NOTE = [/(^|\/)CHANGELOG\.md$/, /(^|\/)package(-lock)?\.jso
|
|
|
163
184
|
const NOTE_LINE = /^\s*Release-note:[ \t]*(?<value>.*?)[ \t]*$/gim;
|
|
164
185
|
/** A note named in a `Release-note:` line: `.changeset/<name>.md`, or the bare `<name>.md`. */
|
|
165
186
|
const NAMED_NOTE = /^(?:\.changeset\/)?(?<name>[\w.-]+\.md)$/;
|
|
187
|
+
/** `Release-note-for: <sha> none — <reason>`: a later commit answering a bare `none` (see the header). */
|
|
188
|
+
const ANSWER_LINE = /^[ \t]*Release-note-for:[ \t]*(?<rest>.*?)[ \t]*$/gim;
|
|
189
|
+
const ANSWER_HEAD = /^(?<sha>[0-9a-f]{7,40})(?<tail>(?:[ \t].*)?)$/i;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* PURE. Every `Release-note-for:` line in the range, judged. `isAncestor(a, b)` says whether commit `a` is
|
|
193
|
+
* an ancestor of commit `b`; without one, range order stands in for it (the range is read oldest first).
|
|
194
|
+
* @returns {{answers: Map<string, {by:string, subject:string, reason:string}>,
|
|
195
|
+
* refused: Array<{sha:string, subject:string, text:string, problem:string}>}}
|
|
196
|
+
*/
|
|
197
|
+
export function readAnswers(commits = [], isAncestor = null) {
|
|
198
|
+
const refused = [];
|
|
199
|
+
const claims = new Map();
|
|
200
|
+
commits.forEach((c, at) => {
|
|
201
|
+
for (const m of String(c.message ?? "").matchAll(ANSWER_LINE)) {
|
|
202
|
+
const text = m[0].trim();
|
|
203
|
+
const bad = (problem) => refused.push({ sha: c.sha, subject: c.subject, text, problem });
|
|
204
|
+
const head = ANSWER_HEAD.exec(m.groups.rest);
|
|
205
|
+
if (!head) { bad("names no commit: the form is `Release-note-for: <sha> none — <reason>`"); continue; }
|
|
206
|
+
const none = NO_NOTE.exec(`Release-note:${head.groups.tail}`);
|
|
207
|
+
if (!none) { bad("does not say `none` straight after one sha: one line answers one commit"); continue; }
|
|
208
|
+
if (!none.groups?.reason) { bad("gives no reason, and the reason is the whole point of the line"); continue; }
|
|
209
|
+
const prefix = head.groups.sha.toLowerCase();
|
|
210
|
+
const hits = commits.filter((k) => String(k.sha).toLowerCase().startsWith(prefix));
|
|
211
|
+
if (!hits.length) { bad(`names ${prefix}, which is not a commit in this range`); continue; }
|
|
212
|
+
if (hits.length > 1) { bad(`names ${prefix}, which matches ${hits.length} commits in this range`); continue; }
|
|
213
|
+
const target = hits[0];
|
|
214
|
+
const later = target.sha !== c.sha
|
|
215
|
+
&& (isAncestor ? isAncestor(target.sha, c.sha) : commits.indexOf(target) < at);
|
|
216
|
+
if (!later) { bad(`names ${prefix}, which is not an earlier commit on this commit's own history`); continue; }
|
|
217
|
+
if (!claims.has(target.sha)) claims.set(target.sha, []);
|
|
218
|
+
claims.get(target.sha).push({ by: c.sha, subject: c.subject, text, reason: none.groups.reason });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
const answers = new Map();
|
|
222
|
+
for (const [sha, list] of claims) {
|
|
223
|
+
if (list.length > 1) {
|
|
224
|
+
for (const a of list) refused.push({ sha: a.by, subject: a.subject, text: a.text,
|
|
225
|
+
problem: `is one of ${list.length} answers naming ${sha.slice(0, 7)}; one commit takes one answer` });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
answers.set(sha, list[0]);
|
|
229
|
+
}
|
|
230
|
+
return { answers, refused };
|
|
231
|
+
}
|
|
166
232
|
|
|
167
233
|
const isNotePath = (p) => p.startsWith(".changeset/") && p.endsWith(".md") && !p.endsWith("README.md");
|
|
168
234
|
const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) => re.test(p)) && shipsCode(p, files));
|
|
@@ -181,12 +247,15 @@ const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) =>
|
|
|
181
247
|
* non-merge, oldest first; `added` and `deleted` are the paths the commit adds and removes, when the caller read them
|
|
182
248
|
* @param {string[]} o.files the package's own `files` list
|
|
183
249
|
* @param {string[]|null} [o.atHead] every path the head carries under `.changeset/`, when the caller read it
|
|
250
|
+
* @param {((a:string, b:string) => boolean)|null} [o.isAncestor] git ancestry for `Release-note-for:` answers; range order without it
|
|
184
251
|
* @returns {{visible:string[], notes:string[], declined:Array<{sha:string, subject:string, reason:string}>,
|
|
185
252
|
* withdrawals:Array<{sha:string, subject:string, note:string, by:string, reason:string}>,
|
|
253
|
+
* answered:Array<{sha:string, subject:string, by:string, reason:string}>,
|
|
254
|
+
* refusedAnswers:Array<{sha:string, subject:string, text:string, problem:string}>,
|
|
186
255
|
* owed:Array<{sha:string, subject:string, why:"no-note"|"prose"|"bare-none"|"names-a-missing-note"|"note-withdrawn-unsaid",
|
|
187
256
|
* paths?:string[], text?:string, by?:string}>}}
|
|
188
257
|
*/
|
|
189
|
-
export function commitVerdicts({ commits = [], files = [], atHead = null } = {}) {
|
|
258
|
+
export function commitVerdicts({ commits = [], files = [], atHead = null, isAncestor = null } = {}) {
|
|
190
259
|
// `added` and `atHead` are what the command reads from git; a caller that passes neither keeps the older
|
|
191
260
|
// reading of every changed path, which is what a pure table of paths means.
|
|
192
261
|
// MATCHED ON THE NAME, NOT THE PATH, BECAUSE A PRE-RELEASE MOVES A NOTE IT CONSUMES.
|
|
@@ -234,7 +303,9 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
|
|
|
234
303
|
});
|
|
235
304
|
// AND A NAME THE HEAD STILL CARRIES WAS NEVER WITHDRAWN AT ALL: the release assembles what the head has.
|
|
236
305
|
if (presentNames) for (const name of [...withdrawn.keys()]) if (presentNames.has(name)) withdrawn.delete(name);
|
|
237
|
-
const declined = [], owed = [], withdrawals = [];
|
|
306
|
+
const declined = [], owed = [], withdrawals = [], answered = [];
|
|
307
|
+
const { answers, refused: refusedAnswers } = readAnswers(commits, isAncestor);
|
|
308
|
+
const used = new Set();
|
|
238
309
|
for (const [i, c] of commits.entries()) {
|
|
239
310
|
const ships = shipped(c.paths, files);
|
|
240
311
|
if (!ships.length) continue; // nothing a reader could see in this commit
|
|
@@ -247,7 +318,10 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
|
|
|
247
318
|
const none = NO_NOTE.exec(String(c.message ?? ""));
|
|
248
319
|
if (none) {
|
|
249
320
|
const reason = none.groups?.reason;
|
|
250
|
-
|
|
321
|
+
const answer = reason ? null : answers.get(c.sha);
|
|
322
|
+
if (reason) declined.push({ ...at, reason });
|
|
323
|
+
else if (answer) { answered.push({ ...at, by: answer.by, reason: answer.reason }); used.add(c.sha); }
|
|
324
|
+
else owed.push({ ...at, why: "bare-none" });
|
|
251
325
|
continue;
|
|
252
326
|
}
|
|
253
327
|
if (addsNote) continue; // it carries its own note
|
|
@@ -268,7 +342,16 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
|
|
|
268
342
|
if (notes.length) continue; // a note in the range answers for it
|
|
269
343
|
owed.push({ ...at, why: "no-note", paths: ships });
|
|
270
344
|
}
|
|
271
|
-
|
|
345
|
+
// AN ANSWER THAT ANSWERED NOTHING IS REFUSED, NOT IGNORED (see the header).
|
|
346
|
+
const owing = new Set(owed.map((o) => o.sha));
|
|
347
|
+
for (const [sha, a] of answers) {
|
|
348
|
+
if (used.has(sha)) continue;
|
|
349
|
+
refusedAnswers.push({ sha: a.by, subject: a.subject, text: a.text,
|
|
350
|
+
problem: owing.has(sha)
|
|
351
|
+
? `names ${sha.slice(0, 7)}, which owes a note this line cannot give: an answer covers only a bare \`none\``
|
|
352
|
+
: `names ${sha.slice(0, 7)}, which owes no answer: it ships no code, carries a note, or gives its own reason` });
|
|
353
|
+
}
|
|
354
|
+
return { visible, notes, declined, withdrawals, owed, answered, refusedAnswers };
|
|
272
355
|
}
|
|
273
356
|
|
|
274
357
|
const argAfter = (flag) => {
|
|
@@ -333,7 +416,9 @@ function main() {
|
|
|
333
416
|
+ "history is never read.");
|
|
334
417
|
return;
|
|
335
418
|
}
|
|
336
|
-
|
|
419
|
+
// ANCESTRY FROM GIT, NEVER TIMESTAMPS: an answer must come from a commit the named one is an ancestor of.
|
|
420
|
+
const isAncestor = (a, b) => { try { git("merge-base", "--is-ancestor", a, b); return true; } catch { return false; } };
|
|
421
|
+
const { visible, notes, declined, withdrawals, owed, answered, refusedAnswers } = commitVerdicts({ commits, files, atHead, isAncestor });
|
|
337
422
|
console.log(`release-note-required: ${changed.length} changed file(s) against ${base}`
|
|
338
423
|
+ `${head === "HEAD" ? "" : ` (head ${head})`}; `
|
|
339
424
|
+ `${visible.length} ship as code; ${notes.length} release note(s) in the range; ${commits.length} commit(s) read`
|
|
@@ -352,7 +437,18 @@ function main() {
|
|
|
352
437
|
for (const w of withdrawals) {
|
|
353
438
|
console.log(` ${w.sha.slice(0, 7)} answered with ${w.note}, which ${w.by.slice(0, 7)} withdrew: ${w.reason}`);
|
|
354
439
|
}
|
|
355
|
-
|
|
440
|
+
for (const a of answered) {
|
|
441
|
+
console.log(` ${a.sha.slice(0, 7)} said a bare \`none\`, which ${a.by.slice(0, 7)} answers: ${a.reason}`);
|
|
442
|
+
}
|
|
443
|
+
if (!owed.length && !refusedAnswers.length) return;
|
|
444
|
+
if (refusedAnswers.length) {
|
|
445
|
+
console.error(`\n${refusedAnswers.length} Release-note-for answer(s) refused:\n`);
|
|
446
|
+
for (const r of refusedAnswers) {
|
|
447
|
+
console.error(` ${r.sha.slice(0, 7)} ${r.subject}`);
|
|
448
|
+
console.error(` "${r.text.slice(0, 120)}" ${r.problem}.`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (!owed.length) process.exit(1);
|
|
356
452
|
|
|
357
453
|
console.error(`\n${owed.length} commit(s) that ship as code owe a release note:\n`);
|
|
358
454
|
for (const o of owed) {
|
|
@@ -0,0 +1,60 @@
|
|
|
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-rehearsal-version.mjs — what a rehearsal publishes: the version a beta cut would publish now.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/release-rehearsal-version.mjs # stdout: publish_dry_run=true|false; the rest on stderr
|
|
7
|
+
//
|
|
8
|
+
// WHY A REHEARSAL DOES NOT PUBLISH MAIN'S VERSION. Main's version is the one already out on almost every
|
|
9
|
+
// day: a cut stamps it and publishes it, and it stays on main until the next cut. npm checks the registry
|
|
10
|
+
// even on `npm publish --dry-run`, so a rehearsal of main's own version answered "You cannot publish over
|
|
11
|
+
// the previously published versions" and went red (measured 2026-09-23 on 0.3.3), reading as a broken
|
|
12
|
+
// pipeline when nothing was wrong. It passed only between a cut and its publish, when nobody rehearses.
|
|
13
|
+
//
|
|
14
|
+
// So a rehearsal stamps the tree the way a `cut: beta` dispatch does, with release-version.mjs itself, and
|
|
15
|
+
// dry-runs that version: the one a cut made now would publish. The stamp lives in the job's own checkout
|
|
16
|
+
// and is never committed.
|
|
17
|
+
//
|
|
18
|
+
// WITH NO NOTE PENDING THERE IS NOTHING TO CUT, and a cut would refuse for exactly that reason. The
|
|
19
|
+
// version is then main's, which is out, so the publish dry run is skipped and says why, while every check
|
|
20
|
+
// before it still runs on those bytes. A note that is pending and still cannot be cut is a real failure,
|
|
21
|
+
// and the rehearsal fails with the version script's own refusal.
|
|
22
|
+
//
|
|
23
|
+
// Exit 0: stamped, or nothing to cut. Exit 1: the version script refused. Exit 2: could not look.
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { join, dirname } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { isEntrypoint } from "../shared/is-entrypoint.mjs";
|
|
28
|
+
import { countNotes } from "./release-pre-gate.mjs";
|
|
29
|
+
|
|
30
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
31
|
+
|
|
32
|
+
/** PURE. What a rehearsal does with the notes it finds pending at the top of `.changeset/`. */
|
|
33
|
+
export function rehearsalPlan({ pendingNotes }) {
|
|
34
|
+
if (!Number.isInteger(pendingNotes) || pendingNotes < 0) return { action: "could-not-look" };
|
|
35
|
+
return pendingNotes > 0 ? { action: "stamp-next-beta" } : { action: "nothing-to-cut" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function main(root = ROOT) {
|
|
39
|
+
let pendingNotes;
|
|
40
|
+
try { pendingNotes = countNotes(join(root, ".changeset")); }
|
|
41
|
+
catch (e) { console.error(`release-rehearsal-version: could not read .changeset/: ${e.message}. Could not look.`); return 2; }
|
|
42
|
+
const plan = rehearsalPlan({ pendingNotes });
|
|
43
|
+
if (plan.action === "could-not-look") { console.error("release-rehearsal-version: could not count the pending notes. Could not look."); return 2; }
|
|
44
|
+
if (plan.action === "nothing-to-cut") {
|
|
45
|
+
console.error("release-rehearsal-version: no release note is pending, so there is nothing to cut and main's version is the one already out.");
|
|
46
|
+
console.log("publish_dry_run=false");
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
console.error(`release-rehearsal-version: ${pendingNotes} note(s) pending; stamping the version a \`cut: beta\` would publish.`);
|
|
50
|
+
// The version script's own output goes to stderr, so stdout carries the one line the workflow keeps.
|
|
51
|
+
const r = spawnSync(process.execPath, [join(root, "scripts", "release-version.mjs"), "--cut=beta"], { cwd: root, stdio: ["ignore", 2, 2] });
|
|
52
|
+
if (r.status !== 0) {
|
|
53
|
+
console.error(`release-rehearsal-version: the version script refused (exit ${r.status ?? r.signal}), so a beta cut made now would refuse too.`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
console.log("publish_dry_run=true");
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (isEntrypoint(import.meta.url)) process.exitCode = main();
|
|
@@ -0,0 +1,104 @@
|
|
|
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-sbom.mjs — the parts list attached to every release, written from package-lock.json.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/release-sbom.mjs [--out <file>] # CycloneDX 1.5 JSON; stdout when --out is absent
|
|
7
|
+
//
|
|
8
|
+
// WHY NOT `npm sbom`. It refuses this tree: `ESBOMPROBLEMS: invalid: uuid@11.1.1 … required by
|
|
9
|
+
// exceljs@4.4.0`, on a clean `npm ci` as well, and `npm ls` flags the same edge. That is npm's reading of
|
|
10
|
+
// the `uuid` override on the bundled `exceljs`, and an exact-version override is flagged the same way
|
|
11
|
+
// (measured 2026-09-23 on npm 10.9.8). The reference tool, `@cyclonedx/cyclonedx-npm`, would add a
|
|
12
|
+
// dependency that runs inside a release job. The lock already names every package, its version, its
|
|
13
|
+
// integrity and its licence, which is what a parts list is.
|
|
14
|
+
//
|
|
15
|
+
// WHAT IS LISTED. Every package the lock installs that is not dev-only: the runtime dependencies and
|
|
16
|
+
// everything they pull in, and the bundled ones this package carries inside its tarball. A consumer's
|
|
17
|
+
// npm resolves the unbundled ones again at install time, so the list is what this release was built and
|
|
18
|
+
// tested with, which is what an SBOM of a library can honestly say. Workspace links are this package's
|
|
19
|
+
// own files, not parts, and are left out; a vendored package is listed by its path in this tree.
|
|
20
|
+
//
|
|
21
|
+
// THE SAME LOCK GIVES THE SAME BYTES. No timestamp and no serial number: both are optional in the spec,
|
|
22
|
+
// and either would make two runs over one lock differ for no reason a reader can use.
|
|
23
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join, dirname } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { isEntrypoint } from "../shared/is-entrypoint.mjs";
|
|
27
|
+
|
|
28
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
+
|
|
30
|
+
/** `pkg:npm/<name>@<version>`, with a scope's `@` encoded as the purl spec asks. */
|
|
31
|
+
export const purlOf = (name, version) => `pkg:npm/${name.startsWith("@") ? `%40${name.slice(1)}` : name}@${version}`;
|
|
32
|
+
|
|
33
|
+
/** An npm integrity string (`sha512-<base64>`) as CycloneDX hashes, hex-encoded. */
|
|
34
|
+
export function hashesOf(integrity) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const part of String(integrity ?? "").split(/\s+/).filter(Boolean)) {
|
|
37
|
+
const m = /^(sha512|sha384|sha256|sha1)-(.+)$/.exec(part);
|
|
38
|
+
if (!m) continue;
|
|
39
|
+
const alg = { sha512: "SHA-512", sha384: "SHA-384", sha256: "SHA-256", sha1: "SHA-1" }[m[1]];
|
|
40
|
+
out.push({ alg, content: Buffer.from(m[2], "base64").toString("hex") });
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The CycloneDX document for a parsed lockfile and the root manifest. PURE.
|
|
47
|
+
* @param {{ lock: object, manifest: object }} input
|
|
48
|
+
*/
|
|
49
|
+
export function sbomFor({ lock, manifest }) {
|
|
50
|
+
const byRef = new Map();
|
|
51
|
+
const workspaces = new Set((manifest.workspaces ?? []).map((w) => w.replace(/\/+$/, "")));
|
|
52
|
+
for (const [path, entry] of Object.entries(lock.packages ?? {})) {
|
|
53
|
+
if (!path.startsWith("node_modules/") && !path.includes("/node_modules/")) continue; // the root and link targets
|
|
54
|
+
if (entry.dev) continue; // dev-only tools
|
|
55
|
+
// A LINK TO A WORKSPACE is this package's own code. A link to anything else is code this package
|
|
56
|
+
// carries in its own tree, such as a vendored replacement, and it is listed by where it lives, with no
|
|
57
|
+
// npm purl: that would name the registry's package of the same name, which it is not.
|
|
58
|
+
if (entry.link) {
|
|
59
|
+
const target = lock.packages?.[entry.resolved] ?? {};
|
|
60
|
+
if (workspaces.has(entry.resolved) || !target.version || target.dev) continue;
|
|
61
|
+
const name = path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length);
|
|
62
|
+
const ref = `file:${entry.resolved}`;
|
|
63
|
+
const vendored = { type: "library", "bom-ref": ref, name, version: target.version,
|
|
64
|
+
properties: [{ name: "clearotron:vendored-path", value: entry.resolved }] };
|
|
65
|
+
if (typeof target.license === "string" && target.license) vendored.licenses = [{ expression: target.license }];
|
|
66
|
+
byRef.set(ref, vendored);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const name = entry.name ?? path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length);
|
|
70
|
+
if (!entry.version) continue;
|
|
71
|
+
const ref = purlOf(name, entry.version);
|
|
72
|
+
if (byRef.has(ref)) continue;
|
|
73
|
+
const component = { type: "library", "bom-ref": ref, name, version: entry.version, purl: ref };
|
|
74
|
+
const hashes = hashesOf(entry.integrity);
|
|
75
|
+
if (hashes.length) component.hashes = hashes;
|
|
76
|
+
if (typeof entry.license === "string" && entry.license) component.licenses = [{ expression: entry.license }];
|
|
77
|
+
byRef.set(ref, component);
|
|
78
|
+
}
|
|
79
|
+
const components = [...byRef.values()].sort((a, b) => a["bom-ref"].localeCompare(b["bom-ref"]));
|
|
80
|
+
const root = { type: "library", "bom-ref": purlOf(manifest.name, manifest.version), name: manifest.name, version: manifest.version,
|
|
81
|
+
purl: purlOf(manifest.name, manifest.version) };
|
|
82
|
+
if (manifest.license) root.licenses = [{ expression: manifest.license }];
|
|
83
|
+
return { bomFormat: "CycloneDX", specVersion: "1.5", version: 1, metadata: { component: root }, components };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function main() {
|
|
87
|
+
const argv = process.argv.slice(2);
|
|
88
|
+
const i = argv.indexOf("--out");
|
|
89
|
+
const out = i === -1 ? null : argv[i + 1];
|
|
90
|
+
if (i !== -1 && !out) { console.error("release-sbom: --out needs a file."); return 2; }
|
|
91
|
+
let lock, manifest;
|
|
92
|
+
try {
|
|
93
|
+
lock = JSON.parse(readFileSync(join(ROOT, "package-lock.json"), "utf8"));
|
|
94
|
+
manifest = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
|
95
|
+
} catch (e) { console.error(`release-sbom: could not read the lock or the manifest: ${e.message}. Could not look.`); return 2; }
|
|
96
|
+
const doc = sbomFor({ lock, manifest });
|
|
97
|
+
if (!doc.components.length) { console.error("release-sbom: the lock lists no runtime package, so there is no parts list to write. Refusing an empty one."); return 1; }
|
|
98
|
+
const text = JSON.stringify(doc, null, 2) + "\n";
|
|
99
|
+
if (out) { writeFileSync(out, text); console.log(`release-sbom: ${doc.components.length} part(s) of ${manifest.name}@${manifest.version} written to ${out}.`); }
|
|
100
|
+
else process.stdout.write(text);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isEntrypoint(import.meta.url)) process.exitCode = main();
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// [--timeout 900] [--interval 15] [--registry https://registry.npmjs.org] [--name clearotron]
|
|
8
8
|
//
|
|
9
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,
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// the one at <file> — the bytes this run published. Exit 1: not served within the bound, which is PENDING:
|
|
11
|
+
// npm accepts a publish before it serves it. Exit 3: the registry serves DIFFERENT BYTES for <v>, which is
|
|
12
|
+
// never pending and must go red at once, so it has a code of its own; it used to share 1, and a caller
|
|
13
|
+
// that waits on 1 then waited six hours on a wrong tarball. Exit 2: could not look — bad arguments, or the
|
|
14
|
+
// local tarball cannot be read. In every case but 0 the caller creates no release entry.
|
|
13
15
|
//
|
|
14
16
|
// ── WHY THIS RUNS BEFORE THE RELEASE ENTRY ─────────────────────────────────────────────────────────
|
|
15
17
|
//
|
|
@@ -89,7 +91,7 @@ async function main() {
|
|
|
89
91
|
console.error(`\nrelease-visible-check: after ${waited}s an unauthenticated client still cannot get ${name}@${version} `
|
|
90
92
|
+ `under \`${tag}\`. The publish was accepted and is not yet served, so no release entry is created: `
|
|
91
93
|
+ "the page would name a version nobody can install. The tag is already written, so the pipeline "
|
|
92
|
-
+ "knows this version is published;
|
|
94
|
+
+ "knows this version is published; the scheduled entry job creates the entry once the registry serves it.");
|
|
93
95
|
process.exit(1);
|
|
94
96
|
}
|
|
95
97
|
await sleep(intervalSec * 1000);
|
|
@@ -108,7 +110,7 @@ async function main() {
|
|
|
108
110
|
console.error(`release-visible-check: the registry serves DIFFERENT BYTES for ${name}@${version}.\n`
|
|
109
111
|
+ ` published by this run: ${local}\n served: ${served}\n recorded integrity: ${seen.integrity ?? "none"}\n`
|
|
110
112
|
+ "No release entry is created for a version whose install is not the build this run verified.");
|
|
111
|
-
process.exit(
|
|
113
|
+
process.exit(3);
|
|
112
114
|
}
|
|
113
115
|
console.log(`release-visible-check: a stranger can install ${name}@${version} under \`${tag}\`, `
|
|
114
116
|
+ `and the tarball served is the one this run published (${served.slice(0, 22)}…).`);
|
package/scripts/score.mjs
CHANGED
|
@@ -435,7 +435,7 @@ function scoreOne(run, ref) {
|
|
|
435
435
|
//
|
|
436
436
|
// Refusing the unshaped pairing was only half of it. A gold set that gains a `counts` block usually
|
|
437
437
|
// KEEPS its lawyer sheet — it is a real answer to a real matter and deleting it loses history — and
|
|
438
|
-
// scoring both prints `LOST ·
|
|
438
|
+
// scoring both prints `LOST · WAVO PUNCH` beside the count that measures the product. That is the same
|
|
439
439
|
// structural zero the issue is about, one row smaller, and it lands in the bucket table a reader
|
|
440
440
|
// scans first.
|
|
441
441
|
//
|
|
@@ -644,7 +644,7 @@ function print(id, ref, run, s, delta, refPath) {
|
|
|
644
644
|
console.log(renderCarryThrough(run.dir, { indent: " " }).lines.join("\n"));
|
|
645
645
|
|
|
646
646
|
// — a record in two buckets is a defect in THIS TOOL, and it is printed as loudly as any score.
|
|
647
|
-
// Silence here is what let `
|
|
647
|
+
// Silence here is what let `KORPHI GENETICS` sit in LOST and `DG KORPHI GENETICS` in NOISE on the same
|
|
648
648
|
// page, and the recall number that reached the round handover was wrong for a week because nobody
|
|
649
649
|
// reads two lists against each other.
|
|
650
650
|
if (B.collisions?.length) {
|
|
@@ -656,7 +656,7 @@ function print(id, ref, run, s, delta, refPath) {
|
|
|
656
656
|
//
|
|
657
657
|
// These used to print under the line above, which tells the reader not to read the recall numbers.
|
|
658
658
|
// One large filer anywhere in a matter therefore suppressed a whole run's measurement, and did: R2's
|
|
659
|
-
// real 88% → 63% recall movement went unquoted on the regression issue because `
|
|
659
|
+
// real 88% → 63% recall movement went unquoted on the regression issue because `Norvanta AG` held one
|
|
660
660
|
// withheld reference mark and one surfaced non-reference mark. Those two rows are both true.
|
|
661
661
|
//
|
|
662
662
|
// Kept visible because a reader may still want the pairing, and deliberately WITHOUT a verdict: this
|
package/shared/brand.mjs
CHANGED
|
@@ -718,7 +718,7 @@ export const CHROME_CSS = `
|
|
|
718
718
|
thing. The three body elements that carried the class drop it too, and --wm-alpha goes with them:
|
|
719
719
|
a token nothing reads is a knob somebody re-wires.
|
|
720
720
|
NOTE FOR ANY FUTURE DECORATION ON body: the rule here pinned right:0 and never a negative inset,
|
|
721
|
-
because right:-2% put 26px of horizontal scrollbar on a 1280px report
|
|
721
|
+
because right:-2% put 26px of horizontal scrollbar on a 1280px report and pointer-events:none
|
|
722
722
|
does not exempt a box from scrollable overflow. That is a property of the position, not of this
|
|
723
723
|
rule, and scripts/report-overflow-check.mjs still measures it. */
|
|
724
724
|
/* Floating settings stack (theme + privacy) — pinned bottom-right, OUT of the nav bar. Icon-only: the toggle keeps its .tt-lbl/.anon-lbl text for screen readers but clips it visually.
|