@rightkit/release 0.4.1 → 0.5.0
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/README.md +11 -2
- package/adhoc-entitlements.mjs +8 -0
- package/advisory-exceptions.mjs +371 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1079 -0
- package/cli/right-release.mjs +29 -0
- package/deterministic-scans.mjs +966 -0
- package/development-signing.mjs +2 -1
- package/host-upload-refusal.mjs +90 -0
- package/local-publish.mjs +9 -0
- package/local-qualify.mjs +728 -0
- package/local-runtime.mjs +319 -0
- package/lock-graph.mjs +51 -0
- package/managed-cargo.mjs +4 -1
- package/ownership-approval.mjs +23 -6
- package/ownership-mandatory-set.json +53 -13
- package/ownership-mandatory-set.mjs +10 -4
- package/package.json +6 -2
- package/pipeline-admission.mjs +1 -1
- package/pipeline-receipts.mjs +302 -0
- package/publication-gate.mjs +12 -1
- package/publish-cargo.mjs +13 -1
- package/rightkit-ownership.json +57 -0
- package/rightkit-versions.json +77 -61
- package/runtime-budget-classes.json +8 -0
- package/scan-digest.mjs +107 -0
- package/sign-qa-bundle.mjs +210 -0
- package/stage-sidecar.mjs +54 -9
- package/workflow-yaml.mjs +177 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `right-release qualify cargo --crates a,b [--hosts win,mac]`: qualify crates LOCALLY, on both native hosts, through the
|
|
3
|
+
// RustRight broker, and turn the result into a committed-by-hash candidate (Adrian 2026-10-21/22). Nothing here publishes.
|
|
4
|
+
// 1. The checkout must be clean and HEAD must be pushed to origin/main; the other host's checkout must be at the same HEAD.
|
|
5
|
+
// 2. Each host (Dell for Windows, Mac for macOS, the Mac reached with `ssh mac "<cmd>"`, the Dell with `win "<cmd>"`) runs
|
|
6
|
+
// `local-qualify.mjs host`: the deterministic scans (class A and B) over the units' scope, then for every crate
|
|
7
|
+
// `right-release publish cargo --crate X --local --qualify-only --bundle <dir>` (tests, clippy, fmt, secret scan, package
|
|
8
|
+
// verify, publication gate) and the archive secret scan of the packed .crate. Both hosts run at once.
|
|
9
|
+
// 3. The bundles come back over the same channel (`export` prints one hash-checked JSON document), the hosts' archive hashes
|
|
10
|
+
// must agree, and this driver seals one packed asset (manifest + .crate + upload metadata) and one `local-verify`
|
|
11
|
+
// receipt per host.
|
|
12
|
+
// 4. A prerelease `candidate-<id>` gets the three assets; the driver prints the candidate JSON and the approval entry for
|
|
13
|
+
// ci/approved-artifacts.json. It never writes the approval: a reviewer commits that, and publish-reviewed.yml then
|
|
14
|
+
// uploads exactly the pinned bytes (docs/CI.md "Local qualification").
|
|
15
|
+
// CI qualification enters through RustRight's rightkit-dispatch publish workflow. Run local qualification separately;
|
|
16
|
+
// legacy --with-ci refuses before effects. A runtime owner without trusted runtime coverage still refuses locally.
|
|
17
|
+
//
|
|
18
|
+
// Any repository that publishes crates this way, not only RightKit: the repository (owner/name) is read from `git remote get-url
|
|
19
|
+
// origin`, and the host checkout roots, the approvals file and the promotion workflow come from a committed `.rightkit-gate.json`
|
|
20
|
+
// { "hosts": { "win": { "root": "D:/..." }, "mac": { "root": "/Volumes/D/..." } },
|
|
21
|
+
// "approvedArtifacts": "ci/approved-artifacts.json", "promotionWorkflow": "publish-reviewed.yml" }
|
|
22
|
+
// RightKit's own values are the defaults only when the origin is bogusyogi/rightkit; any other repository must declare every value it
|
|
23
|
+
// uses, and a missing one refuses by name (loadQualifyConfig). The repository must also ship the CI scripts qualification loads from
|
|
24
|
+
// its own checkout (scripts/ci: scan-receipt, reviewed-candidate, upload, publish, archive-secret-scan) and keep crates under crates/.
|
|
25
|
+
// This is crate qualification for a registry upload. It is NOT an app's "release qualified locally on both hosts": for a product
|
|
26
|
+
// repository that evidence is the app's own `right-release` release QA on both hosts (docs/CI.md "Release evidence for an app").
|
|
27
|
+
import crypto from "node:crypto";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import zlib from "node:zlib";
|
|
31
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
32
|
+
import { spawnSync } from "./background-process.mjs";
|
|
33
|
+
import { managedRuntimeClient, spawnCollect } from "./managed-cargo.mjs";
|
|
34
|
+
import { planScans, runScans } from "./deterministic-scans.mjs";
|
|
35
|
+
import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
|
|
36
|
+
import { deriveCrateLayers } from "./local-publish.mjs";
|
|
37
|
+
import { RUNTIME_BUDGET_CRATES } from "./runtime-budget-crates.mjs";
|
|
38
|
+
import { measureRuntimeCrate, bindRuntimeManifest } from "./local-runtime.mjs";
|
|
39
|
+
|
|
40
|
+
export const REPOSITORY = "bogusyogi/rightkit";
|
|
41
|
+
export class QualifyError extends Error {
|
|
42
|
+
constructor(message) { super(message); this.name = "QualifyError"; }
|
|
43
|
+
}
|
|
44
|
+
const refuse = (message) => { throw new QualifyError(message); };
|
|
45
|
+
const CI_DISPATCH_REFUSAL = "--with-ci raw GitHub dispatch is retired; use RustRight broker rightkit-dispatch publish workflow with request_id & expected_sha; run local qualification separately without --with-ci";
|
|
46
|
+
|
|
47
|
+
// Host table. `reach` is how the OTHER host reaches this one: the Dell runs `ssh mac "<cmd>"`, the Mac runs `win "<cmd>"`. The `root`
|
|
48
|
+
// values are RightKit's own checkout locations, the defaults of the config below only when the origin is bogusyogi/rightkit.
|
|
49
|
+
export const HOSTS = Object.freeze({
|
|
50
|
+
win: Object.freeze({ key: "win", name: "windows", os: "win32", root: "D:/Claude/rightkit", scratch: "D:/scratch/rk-localq", reach: ["win"] }),
|
|
51
|
+
mac: Object.freeze({ key: "mac", name: "macos", os: "darwin", root: "/Volumes/D/claude/rightkit", scratch: "/Volumes/D/scratch/rk-localq", reach: ["ssh", "mac"] }),
|
|
52
|
+
});
|
|
53
|
+
const CANONICAL_HOST = "mac"; // CI packs crates on the macOS runner; the same host's bytes are the sealed bytes
|
|
54
|
+
const CRATE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
55
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,79}$/;
|
|
56
|
+
const SHA40 = /^[0-9a-f]{40}$/;
|
|
57
|
+
const SHA64 = /^[0-9a-f]{64}$/;
|
|
58
|
+
// Arguments reach a remote shell unquoted, so only plain path/list tokens are allowed; anything else refuses.
|
|
59
|
+
const SAFE_TOKEN = /^[A-Za-z0-9._:/\\,=@+-]+$/;
|
|
60
|
+
const HOST_TIMEOUT_MS = 4 * 60 * 60 * 1000;
|
|
61
|
+
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
|
62
|
+
const tail = (text, lines = 40) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
|
|
63
|
+
const list = (text, what) => {
|
|
64
|
+
const names = String(text ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
65
|
+
for (const name of names) if (!CRATE_NAME.test(name)) refuse(`${what}: invalid crate name ${JSON.stringify(name)}`);
|
|
66
|
+
return names;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const GATE_CONFIG_FILE = ".rightkit-gate.json";
|
|
70
|
+
/** RightKit's own values: used for missing config only when the origin is bogusyogi/rightkit. */
|
|
71
|
+
export const DEFAULT_QUALIFY_CONFIG = Object.freeze({
|
|
72
|
+
repository: REPOSITORY,
|
|
73
|
+
hosts: Object.freeze({ win: Object.freeze({ root: HOSTS.win.root }), mac: Object.freeze({ root: HOSTS.mac.root }) }),
|
|
74
|
+
approvedArtifacts: "ci/approved-artifacts.json",
|
|
75
|
+
promotionWorkflow: "publish-reviewed.yml",
|
|
76
|
+
});
|
|
77
|
+
const ORIGIN_URL = /^(?:https?:\/\/(?:[^@/\s]+@)?github\.com\/|ssh:\/\/git@github\.com(?:-[A-Za-z0-9._-]+)?(?::\d+)?\/|git@github\.com(?:-[A-Za-z0-9._-]+)?:)([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/;
|
|
78
|
+
const REPOSITORY_SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
79
|
+
const APPROVALS_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\.json$/;
|
|
80
|
+
const WORKFLOW_FILE = /^[A-Za-z0-9._-]+\.ya?ml$/;
|
|
81
|
+
|
|
82
|
+
/** owner/name from a GitHub remote URL (https, ssh://, scp-style, with or without .git or a ssh host alias), else null. */
|
|
83
|
+
export function repositoryFromOrigin(url) {
|
|
84
|
+
const m = ORIGIN_URL.exec(String(url ?? "").trim());
|
|
85
|
+
if (!m || m[1] === "." || m[1] === ".." || m[2] === "." || m[2] === "..") return null;
|
|
86
|
+
const slug = `${m[1]}/${m[2]}`;
|
|
87
|
+
return slug.toLowerCase() === REPOSITORY.toLowerCase() ? REPOSITORY : slug;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The repository this qualification belongs to and where its pieces live: { repository, hosts: { win?: { root }, mac?: { root } },
|
|
92
|
+
* approvedArtifacts, promotionWorkflow }. The repository is the `origin` remote; the rest is the committed `.rightkit-gate.json`
|
|
93
|
+
* (unknown keys refuse). A value the file omits takes RightKit's default only when the repository is bogusyogi/rightkit; for any
|
|
94
|
+
* other repository a missing value refuses by name, and an absent file refuses with the file to write. A host root is only
|
|
95
|
+
* required later, for a host that is actually asked to run (hostRoot).
|
|
96
|
+
*/
|
|
97
|
+
export function loadQualifyConfig({ root, git, readText = (file) => fs.readFileSync(file, "utf8"), exists = fs.existsSync }) {
|
|
98
|
+
const origin = git(["remote", "get-url", "origin"], root);
|
|
99
|
+
const repository = repositoryFromOrigin(origin);
|
|
100
|
+
if (!repository) refuse(`cannot derive owner/name from \`git remote get-url origin\` (${origin === null || origin === undefined || origin === "" ? "no origin remote" : JSON.stringify(String(origin).replace(/\/\/[^@/\s]*@/, "//"))}); the repository must have a GitHub origin`);
|
|
101
|
+
const own = repository === DEFAULT_QUALIFY_CONFIG.repository;
|
|
102
|
+
const file = path.join(root, GATE_CONFIG_FILE);
|
|
103
|
+
let doc = {};
|
|
104
|
+
if (exists(file)) {
|
|
105
|
+
try { doc = JSON.parse(String(readText(file)).replace(/^\uFEFF/, "")); } catch (error) { refuse(`${GATE_CONFIG_FILE} is not valid JSON: ${error.message}`); }
|
|
106
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) refuse(`${GATE_CONFIG_FILE} must be a JSON object`);
|
|
107
|
+
for (const key of Object.keys(doc)) if (!["hosts", "approvedArtifacts", "promotionWorkflow"].includes(key)) refuse(`${GATE_CONFIG_FILE}: unknown key ${JSON.stringify(key)} (known: hosts, approvedArtifacts, promotionWorkflow)`);
|
|
108
|
+
} else if (!own) {
|
|
109
|
+
refuse(`${repository} has no ${GATE_CONFIG_FILE} at its root and no built-in values (only ${DEFAULT_QUALIFY_CONFIG.repository} has); commit one: { "hosts": { "win": { "root": "<checkout on the Dell>" }, "mac": { "root": "<checkout on the Mac>" } }, "approvedArtifacts": "ci/approved-artifacts.json", "promotionWorkflow": "publish-reviewed.yml" }`);
|
|
110
|
+
}
|
|
111
|
+
const missing = (what) => refuse(`${GATE_CONFIG_FILE} does not set ${what}, and only ${DEFAULT_QUALIFY_CONFIG.repository} has a default for it`);
|
|
112
|
+
const approvedArtifacts = doc.approvedArtifacts ?? (own ? DEFAULT_QUALIFY_CONFIG.approvedArtifacts : missing("approvedArtifacts"));
|
|
113
|
+
if (typeof approvedArtifacts !== "string" || !APPROVALS_PATH.test(approvedArtifacts) || approvedArtifacts.split("/").includes("..")) refuse(`${GATE_CONFIG_FILE}: approvedArtifacts must be a plain repository-relative .json path`);
|
|
114
|
+
const promotionWorkflow = doc.promotionWorkflow ?? (own ? DEFAULT_QUALIFY_CONFIG.promotionWorkflow : missing("promotionWorkflow"));
|
|
115
|
+
if (typeof promotionWorkflow !== "string" || !WORKFLOW_FILE.test(promotionWorkflow)) refuse(`${GATE_CONFIG_FILE}: promotionWorkflow must be a workflow file name such as publish-reviewed.yml`);
|
|
116
|
+
if (doc.hosts !== undefined && (!doc.hosts || typeof doc.hosts !== "object" || Array.isArray(doc.hosts))) refuse(`${GATE_CONFIG_FILE}: hosts must be an object keyed by win and mac`);
|
|
117
|
+
const hosts = {};
|
|
118
|
+
for (const key of Object.keys(doc.hosts ?? {})) if (!Object.hasOwn(HOSTS, key)) refuse(`${GATE_CONFIG_FILE}: unknown host ${JSON.stringify(key)} (win or mac)`);
|
|
119
|
+
for (const key of Object.keys(HOSTS)) {
|
|
120
|
+
const declared = doc.hosts?.[key];
|
|
121
|
+
if (declared !== undefined) {
|
|
122
|
+
if (!declared || typeof declared !== "object" || Array.isArray(declared) || typeof declared.root !== "string" || !SAFE_TOKEN.test(declared.root)) refuse(`${GATE_CONFIG_FILE}: hosts.${key}.root must be a plain checkout path`);
|
|
123
|
+
hosts[key] = { root: declared.root };
|
|
124
|
+
} else if (own) hosts[key] = { root: DEFAULT_QUALIFY_CONFIG.hosts[key].root };
|
|
125
|
+
}
|
|
126
|
+
return { repository, hosts, approvedArtifacts, promotionWorkflow };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The checkout root of `key` on that host: the --win-root / --mac-root flag, else the config; refuses by name when neither has it. */
|
|
130
|
+
export function hostRoot({ key, flags = {}, config }) {
|
|
131
|
+
const root = flags[key] ?? config.hosts[key]?.root;
|
|
132
|
+
if (typeof root !== "string" || !SAFE_TOKEN.test(root)) refuse(`no checkout root for the ${HOSTS[key].name} host: set hosts.${key}.root in ${GATE_CONFIG_FILE} or pass --${key}-root (needed because --hosts includes ${key})`);
|
|
133
|
+
return root;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Where the OTHER host finds this script in its checkout: the source layout, or the installed package (node_modules/@rightkit/release). */
|
|
137
|
+
export function remoteScriptPath(root, file = fileURLToPath(import.meta.url)) {
|
|
138
|
+
const rel = path.relative(root, file).split(path.sep).join("/");
|
|
139
|
+
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.includes("node_modules/") ? "node_modules/@rightkit/release/local-qualify.mjs" : rel;
|
|
140
|
+
return /[\\/]node_modules[\\/]/.test(file) ? "node_modules/@rightkit/release/local-qualify.mjs" : "packages/release/local-qualify.mjs";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const localHostKey = (platform = process.platform) => (platform === "win32" ? "win" : platform === "darwin" ? "mac" : refuse(`local qualification runs on the Dell (win32) or the Mac (darwin), not ${platform}`));
|
|
144
|
+
export const otherHostKey = (key) => (key === "win" ? "mac" : "win");
|
|
145
|
+
|
|
146
|
+
// ---- arguments --------------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/** `qualify cargo --crates a,b [--hosts win,mac] [--id <id>] [--allow-ignored-units a] [--jobs n] [--concurrency n] [--out <dir>] [--win-root <dir>] [--mac-root <dir>] [--no-release]` */
|
|
149
|
+
export function parseQualifyArgs(argv, platform = process.platform) {
|
|
150
|
+
if (argv.includes("--with-ci")) refuse(CI_DISPATCH_REFUSAL);
|
|
151
|
+
const [kind, ...rest] = argv;
|
|
152
|
+
if (kind === "npm") refuse("npm has no local qualification: publish cargo --local --qualify-only is cargo only; qualify npm packages with publish.yml");
|
|
153
|
+
if (kind !== "cargo") refuse("usage: right-release qualify cargo --crates <a,b> [--hosts win,mac] [--id <id>] [--allow-ignored-units <a,b>] [--jobs <n>] [--concurrency <n>] [--out <dir>] [--no-release]");
|
|
154
|
+
// `roots` holds only the checkout roots given on the command line; runQualify fills the rest from .rightkit-gate.json (hostRoot).
|
|
155
|
+
const out = { kind, crates: [], hosts: ["win", "mac"], id: undefined, allowIgnoredUnits: [], jobs: undefined, concurrency: 2, runtimeRepeats: 3, waitIdleMin: 30, outRoot: undefined, roots: {}, release: true, withCi: false };
|
|
156
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
157
|
+
const arg = rest[i];
|
|
158
|
+
if (arg === "--crates" || arg === "--crate") out.crates = list(rest[++i], arg);
|
|
159
|
+
else if (arg === "--hosts") {
|
|
160
|
+
out.hosts = String(rest[++i] ?? "").split(",").map((host) => host.trim()).filter(Boolean);
|
|
161
|
+
if (!out.hosts.length || out.hosts.some((host) => !Object.hasOwn(HOSTS, host)) || new Set(out.hosts).size !== out.hosts.length) refuse("--hosts takes win, mac or win,mac");
|
|
162
|
+
} else if (arg === "--id") out.id = rest[++i];
|
|
163
|
+
else if (arg === "--allow-ignored-units") out.allowIgnoredUnits = list(rest[++i], arg);
|
|
164
|
+
else if (arg === "--jobs") { out.jobs = Number(rest[++i]); if (!Number.isInteger(out.jobs) || out.jobs < 1 || out.jobs > 64) refuse("--jobs must be an integer from 1 to 64"); }
|
|
165
|
+
else if (arg === "--concurrency") { out.concurrency = Number(rest[++i]); if (!Number.isInteger(out.concurrency) || out.concurrency < 1 || out.concurrency > 8) refuse("--concurrency must be an integer from 1 to 8"); }
|
|
166
|
+
else if (arg === "--runtime-repeats") { out.runtimeRepeats = Number(rest[++i]); if (!Number.isInteger(out.runtimeRepeats) || out.runtimeRepeats < 3 || out.runtimeRepeats > 9) refuse("--runtime-repeats must be an integer from 3 to 9"); }
|
|
167
|
+
else if (arg === "--wait-idle-min") { out.waitIdleMin = Number(rest[++i]); if (!Number.isInteger(out.waitIdleMin) || out.waitIdleMin < 0 || out.waitIdleMin > 240) refuse("--wait-idle-min must be an integer from 0 to 240"); }
|
|
168
|
+
else if (arg === "--out") out.outRoot = rest[++i];
|
|
169
|
+
else if (arg === "--win-root") out.roots.win = rest[++i];
|
|
170
|
+
else if (arg === "--mac-root") out.roots.mac = rest[++i];
|
|
171
|
+
else if (arg === "--no-release") out.release = false;
|
|
172
|
+
else refuse(`unknown qualify option: ${arg}`);
|
|
173
|
+
}
|
|
174
|
+
if (!out.crates.length) refuse("qualify cargo requires --crates <a,b>");
|
|
175
|
+
if (new Set(out.crates).size !== out.crates.length) refuse("--crates names a crate more than once");
|
|
176
|
+
for (const name of out.allowIgnoredUnits) if (!out.crates.includes(name)) refuse(`--allow-ignored-units names ${name}, which is not in --crates`);
|
|
177
|
+
if (out.id !== undefined && !ID.test(out.id)) refuse("--id must match [a-z0-9][a-z0-9-]{0,79}");
|
|
178
|
+
for (const [key, value] of Object.entries(out.roots)) if (typeof value !== "string" || !SAFE_TOKEN.test(value)) refuse(`--${key}-root must be a plain path`);
|
|
179
|
+
out.outRoot = out.outRoot ?? HOSTS[localHostKey(platform)].scratch;
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `local-qualify.mjs host ...`: what one host is asked to do. Produced by hostArgs, parsed here. */
|
|
184
|
+
export function parseHostArgs(argv) {
|
|
185
|
+
const out = { root: undefined, id: undefined, commit: undefined, crates: [], out: undefined, exclude: [], allowIgnoredUnits: [], jobs: undefined, concurrency: 2, runtimeRepeats: 3, waitIdleMin: 30, canonical: false };
|
|
186
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
187
|
+
const arg = argv[i];
|
|
188
|
+
if (arg === "--root") out.root = argv[++i];
|
|
189
|
+
else if (arg === "--id") out.id = argv[++i];
|
|
190
|
+
else if (arg === "--commit") out.commit = argv[++i];
|
|
191
|
+
else if (arg === "--crates") out.crates = list(argv[++i], arg);
|
|
192
|
+
else if (arg === "--out") out.out = argv[++i];
|
|
193
|
+
else if (arg === "--exclude") out.exclude = list(argv[++i], arg);
|
|
194
|
+
else if (arg === "--allow-ignored-units") out.allowIgnoredUnits = list(argv[++i], arg);
|
|
195
|
+
else if (arg === "--jobs") out.jobs = Number(argv[++i]);
|
|
196
|
+
else if (arg === "--concurrency") out.concurrency = Number(argv[++i]);
|
|
197
|
+
else if (arg === "--runtime-repeats") out.runtimeRepeats = Number(argv[++i]);
|
|
198
|
+
else if (arg === "--wait-idle-min") out.waitIdleMin = Number(argv[++i]);
|
|
199
|
+
else if (arg === "--canonical") out.canonical = true;
|
|
200
|
+
else if (arg === "--repository") { out.repository = argv[++i]; if (!REPOSITORY_SLUG.test(out.repository ?? "")) refuse("--repository must be owner/name"); }
|
|
201
|
+
else refuse(`unknown host option: ${arg}`);
|
|
202
|
+
}
|
|
203
|
+
if (!out.root || !out.out || !ID.test(out.id ?? "") || !SHA40.test(out.commit ?? "") || !out.crates.length) refuse("host needs --root, --out, --id, --commit and --crates");
|
|
204
|
+
if (out.jobs !== undefined && !(Number.isInteger(out.jobs) && out.jobs >= 1 && out.jobs <= 64)) refuse("--jobs must be an integer from 1 to 64");
|
|
205
|
+
if (!Number.isInteger(out.concurrency) || out.concurrency < 1 || out.concurrency > 8) refuse("--concurrency must be an integer from 1 to 8");
|
|
206
|
+
if (!Number.isInteger(out.runtimeRepeats) || out.runtimeRepeats < 3 || out.runtimeRepeats > 9) refuse("--runtime-repeats must be an integer from 3 to 9");
|
|
207
|
+
if (!Number.isInteger(out.waitIdleMin) || out.waitIdleMin < 0 || out.waitIdleMin > 240) refuse("--wait-idle-min must be an integer from 0 to 240");
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function hostArgs({ root, id, commit, crates, out, exclude = [], allowIgnoredUnits = [], jobs, concurrency, runtimeRepeats = 3, waitIdleMin = 30, canonical, repository }) {
|
|
212
|
+
const args = ["host", "--root", root, "--id", id, "--commit", commit, "--crates", crates.join(","), "--out", out, "--concurrency", String(concurrency ?? 2), "--runtime-repeats", String(runtimeRepeats), "--wait-idle-min", String(waitIdleMin)];
|
|
213
|
+
if (exclude.length) args.push("--exclude", exclude.join(","));
|
|
214
|
+
if (allowIgnoredUnits.length) args.push("--allow-ignored-units", allowIgnoredUnits.join(","));
|
|
215
|
+
if (jobs !== undefined) args.push("--jobs", String(jobs));
|
|
216
|
+
if (canonical) args.push("--canonical");
|
|
217
|
+
if (repository !== undefined) args.push("--repository", repository);
|
|
218
|
+
return args;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The one string the other host's `ssh mac` / `win` runs. Every token is checked: nothing is quoted or interpolated. */
|
|
222
|
+
export function remoteCommand({ root, args, node = "node", script = "packages/release/local-qualify.mjs" }) {
|
|
223
|
+
const tokens = [node, `${root}/${script}`, ...args];
|
|
224
|
+
for (const token of tokens) if (!SAFE_TOKEN.test(token)) refuse(`refusing to send ${JSON.stringify(token)} to a remote shell`);
|
|
225
|
+
return tokens.join(" ");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Spawn arguments that reach `hostKey` from the host running this process. */
|
|
229
|
+
export function reachArgs(fromKey, command) {
|
|
230
|
+
const [bin, ...prefix] = HOSTS[otherHostKey(fromKey)].reach;
|
|
231
|
+
return { command: bin, args: [...prefix, command] };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---- plan -------------------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
const defaultGit = (args, cwd) => {
|
|
237
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
|
|
238
|
+
if (result.error || result.status !== 0) return null;
|
|
239
|
+
return (result.stdout ?? "").trim();
|
|
240
|
+
};
|
|
241
|
+
const defaultReadJson = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
|
|
242
|
+
|
|
243
|
+
/** The checkout this candidate is cut from: clean, HEAD on origin/main. Returns { head, tree }. */
|
|
244
|
+
export function checkoutState({ git, root }) {
|
|
245
|
+
const head = git(["rev-parse", "HEAD"], root);
|
|
246
|
+
if (!SHA40.test(head ?? "")) refuse("cannot resolve HEAD");
|
|
247
|
+
const tree = git(["rev-parse", "HEAD^{tree}"], root);
|
|
248
|
+
if (!SHA40.test(tree ?? "")) refuse("cannot resolve the HEAD tree");
|
|
249
|
+
const dirty = git(["status", "--porcelain"], root);
|
|
250
|
+
if (dirty === null) refuse("cannot read git status");
|
|
251
|
+
if (dirty !== "") refuse(`the checkout is not clean; commit or stash first (a qualified candidate is exactly HEAD):\n${dirty}`);
|
|
252
|
+
const remote = String(git(["ls-remote", "origin", "refs/heads/main"], root) ?? "").split(/\s+/)[0];
|
|
253
|
+
if (!SHA40.test(remote)) refuse("cannot read origin/main");
|
|
254
|
+
if (remote !== head && (git(["cat-file", "-e", `${remote}^{commit}`], root) === null || git(["merge-base", "--is-ancestor", head, remote], root) === null)) refuse(`HEAD ${head.slice(0, 12)} is not pushed to origin/main (${remote.slice(0, 12)}); push it first (fetch if origin/main is ahead)`);
|
|
255
|
+
return { head, tree };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Units, dependency-first, with the allowsets this request implies. A unit gate.config.json restricts away from Windows is a
|
|
260
|
+
* host exclusion; one restricted away from macOS cannot be qualified locally and refuses. A unit that needs a Linux row has proof
|
|
261
|
+
* only a broker-dispatched GitHub run can give. The legacy pure planner's `withCi` option still describes that requirement in
|
|
262
|
+
* `ciRequired`; the local driver no longer dispatches CI. A runtime owner with no trusted runtime coverage refuses either way.
|
|
263
|
+
*/
|
|
264
|
+
export function planUnits({ root, crates, readJson = defaultReadJson, readPackage = readCargoPackage, dependsOn, runtimeCoverage = [], withCi = false }) {
|
|
265
|
+
const gateConfig = readJson(path.join(root, "ci", "gate.config.json")) ?? {};
|
|
266
|
+
const assetGated = (readJson(path.join(root, "ci", "asset-gated.json")) ?? {}).crates ?? {};
|
|
267
|
+
const linux = readJson(path.join(root, "ci", "linux-required.json"));
|
|
268
|
+
if (!linux || linux.schema !== 1 || !Array.isArray(linux.crates)) refuse("ci/linux-required.json is missing or malformed; the Linux requirement cannot be derived");
|
|
269
|
+
const members = new Set(crates);
|
|
270
|
+
const deps = dependsOn ?? ((name) => pathDependencyDirs(path.join(root, "crates", name)).map((dir) => path.basename(dir)).filter((dep) => members.has(dep) && dep !== name));
|
|
271
|
+
const info = new Map(); const ciOnly = new Set();
|
|
272
|
+
for (const name of crates) {
|
|
273
|
+
const dir = `crates/${name}`;
|
|
274
|
+
if (!fs.existsSync(path.join(root, dir))) refuse(`unit directory not found: ${dir}`);
|
|
275
|
+
const pkg = readPackage(path.join(root, dir));
|
|
276
|
+
if (pkg.name !== name) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${name}`);
|
|
277
|
+
if (typeof pkg.version !== "string" || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(pkg.version)) refuse(`${name}: version ${JSON.stringify(pkg.version)} is not a plain x.y.z release version`);
|
|
278
|
+
if (linux.crates.includes(name)) {
|
|
279
|
+
if (!withCi) refuse(`${name} requires a Linux receipt, which local qualification cannot produce; qualify it through RustRight broker rightkit-dispatch publish workflow (--with-ci is retired)`);
|
|
280
|
+
ciOnly.add(name);
|
|
281
|
+
}
|
|
282
|
+
// A continuous runtime owner is qualified locally with a runtime proof on each host (local-runtime.mjs). That needs trusted
|
|
283
|
+
// workloads to run: a listed owner without pinned coverage has none, so no host can prove it (CI cannot either).
|
|
284
|
+
if (RUNTIME_BUDGET_CRATES.includes(name) && !runtimeCoverage.includes(name)) refuse(`${name} is a continuous runtime owner with no trusted runtime coverage (scripts/ci/runtime-qualify.mjs TRUSTED_COVERAGE), so there is no workload for any host to run (--with-ci does not help: publish.yml has no runtime workload either); pin its coverage first`);
|
|
285
|
+
const hosts = gateConfig.rust?.[name]?.os;
|
|
286
|
+
if (RUNTIME_BUDGET_CRATES.includes(name) && hosts && !(hosts.includes("windows") && hosts.includes("macos"))) refuse(`${name}: a runtime owner needs its runtime proof on both native hosts, but ci/gate.config.json restricts it to ${hosts.join(", ")}`);
|
|
287
|
+
if (hosts && !hosts.includes("macos")) refuse(`${name}: ci/gate.config.json restricts it to ${hosts.join(", ")}; crates are packed on macOS`);
|
|
288
|
+
info.set(name, { name, version: pkg.version, dir, excludedOn: hosts && !hosts.includes("windows") ? ["win"] : [] });
|
|
289
|
+
}
|
|
290
|
+
const order = deriveCrateLayers(crates, deps).flat();
|
|
291
|
+
return {
|
|
292
|
+
units: order.map((name) => info.get(name)), layers: deriveCrateLayers(crates, deps),
|
|
293
|
+
allow: { hostExclusion: order.filter((name) => info.get(name).excludedOn.length), noTest: [], ignored: [], missingAssets: order.filter((name) => Object.hasOwn(assetGated, name)) },
|
|
294
|
+
ciRequired: order.filter((name) => ciOnly.has(name)),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function candidateIdFor({ crates, version, head }) {
|
|
299
|
+
const slug = crates.map((name) => name.replace(/^rightkit-/, "")).join("-").slice(0, 44).replace(/-+$/, "");
|
|
300
|
+
const id = ["local", slug, crates.length === 1 && version ? version.replaceAll(".", "") : null, head.slice(0, 6)].filter(Boolean).join("-");
|
|
301
|
+
if (!ID.test(id)) refuse(`derived candidate id ${id} is invalid; pass --id`);
|
|
302
|
+
return id;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---- bundles ----------------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => (entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)]));
|
|
308
|
+
const BUNDLE_MANIFEST = "bundle.manifest.json";
|
|
309
|
+
const SAFE_RELATIVE = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
|
|
310
|
+
|
|
311
|
+
/** Hash every file of a host output directory into bundle.manifest.json (written last). */
|
|
312
|
+
export function writeBundleManifest(dir) {
|
|
313
|
+
const files = walk(dir).map((file) => path.relative(dir, file).split(path.sep).join("/")).filter((rel) => rel !== BUNDLE_MANIFEST).sort()
|
|
314
|
+
.map((rel) => ({ path: rel, sha256: sha256(fs.readFileSync(path.join(dir, rel))) }));
|
|
315
|
+
fs.writeFileSync(path.join(dir, BUNDLE_MANIFEST), `${JSON.stringify({ schema: 1, files }, null, 2)}\n`);
|
|
316
|
+
return files;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Re-hash a bundle against its manifest and refuse extra, missing or altered files. Returns the manifest. */
|
|
320
|
+
export function verifyBundle(dir) {
|
|
321
|
+
let manifest;
|
|
322
|
+
try { manifest = JSON.parse(fs.readFileSync(path.join(dir, BUNDLE_MANIFEST), "utf8")); } catch { refuse(`${dir} has no readable ${BUNDLE_MANIFEST}`); }
|
|
323
|
+
if (manifest?.schema !== 1 || !Array.isArray(manifest.files)) refuse(`${dir}: unknown bundle manifest`);
|
|
324
|
+
const listed = new Set();
|
|
325
|
+
for (const row of manifest.files) {
|
|
326
|
+
if (!SAFE_RELATIVE.test(row?.path ?? "") || !SHA64.test(row.sha256 ?? "") || listed.has(row.path)) refuse(`${dir}: malformed bundle manifest row`);
|
|
327
|
+
listed.add(row.path);
|
|
328
|
+
let bytes;
|
|
329
|
+
try { bytes = fs.readFileSync(path.join(dir, ...row.path.split("/"))); } catch { refuse(`${dir}: ${row.path} is missing`); }
|
|
330
|
+
if (sha256(bytes) !== row.sha256) refuse(`${dir}: ${row.path} changed after the host wrote it`);
|
|
331
|
+
}
|
|
332
|
+
for (const file of walk(dir)) {
|
|
333
|
+
const rel = path.relative(dir, file).split(path.sep).join("/");
|
|
334
|
+
if (rel !== BUNDLE_MANIFEST && !listed.has(rel)) refuse(`${dir}: unlisted file ${rel}`);
|
|
335
|
+
}
|
|
336
|
+
return manifest;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** `export --dir <d>`: one JSON document holding a verified bundle, so any exec channel can carry it. */
|
|
340
|
+
export function exportBundle(dir) {
|
|
341
|
+
const manifest = verifyBundle(dir);
|
|
342
|
+
return JSON.stringify({ schema: 1, files: [...manifest.files.map((row) => ({ path: row.path, sha256: row.sha256, b64: fs.readFileSync(path.join(dir, ...row.path.split("/"))).toString("base64") })), { path: BUNDLE_MANIFEST, sha256: sha256(fs.readFileSync(path.join(dir, BUNDLE_MANIFEST))), b64: fs.readFileSync(path.join(dir, BUNDLE_MANIFEST)).toString("base64") }] });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Write an exported bundle into a NEW directory, checking every path and hash, then re-verify it as a bundle. */
|
|
346
|
+
export function importBundle(text, dest) {
|
|
347
|
+
let doc;
|
|
348
|
+
try { doc = JSON.parse(text); } catch { refuse("the remote export is not JSON"); }
|
|
349
|
+
if (doc?.schema !== 1 || !Array.isArray(doc.files) || !doc.files.length) refuse("the remote export is empty or of an unknown schema");
|
|
350
|
+
if (fs.existsSync(dest)) refuse(`bundle destination already exists: ${dest}`);
|
|
351
|
+
const seen = new Set();
|
|
352
|
+
for (const row of doc.files) {
|
|
353
|
+
if (!SAFE_RELATIVE.test(row?.path ?? "") || seen.has(row.path) || !SHA64.test(row.sha256 ?? "") || typeof row.b64 !== "string") refuse("the remote export holds a malformed file row");
|
|
354
|
+
seen.add(row.path);
|
|
355
|
+
const bytes = Buffer.from(row.b64, "base64");
|
|
356
|
+
if (sha256(bytes) !== row.sha256) refuse(`the remote export altered ${row.path} in transit`);
|
|
357
|
+
const file = path.join(dest, ...row.path.split("/"));
|
|
358
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
359
|
+
fs.writeFileSync(file, bytes, { flag: "wx" });
|
|
360
|
+
}
|
|
361
|
+
verifyBundle(dest);
|
|
362
|
+
return dest;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---- one host ---------------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
function brokerIdentity(deps) {
|
|
368
|
+
const client = (deps.managedRuntimeClient ?? managedRuntimeClient)();
|
|
369
|
+
if (!client) refuse("the RustRight client is not installed on this host; qualification runs only through the broker, never plain cargo");
|
|
370
|
+
return { name: "rustright", client: String(client).replaceAll("\\", "/"), sha256: sha256((deps.readFile ?? fs.readFileSync)(client)) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Cargo only through the broker: `rightkit cargo`, or a `cargo` on PATH that is the managed shim (scripts/local-gate.mjs).
|
|
374
|
+
function detectCargoCommand(deps) {
|
|
375
|
+
const which = (command) => {
|
|
376
|
+
const result = (deps.run ?? spawnSync)((deps.platform ?? process.platform) === "win32" ? "where" : "which", [command], { encoding: "utf8", windowsHide: true });
|
|
377
|
+
return result.status === 0 ? String(result.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean) : [];
|
|
378
|
+
};
|
|
379
|
+
if (which("rightkit").length) return ["rightkit", "cargo"];
|
|
380
|
+
const first = which("cargo")[0];
|
|
381
|
+
if (first) { try { if (fs.readFileSync(first, "utf8").includes("cargo-guard.mjs")) return ["cargo"]; } catch { /* not the managed shim */ } }
|
|
382
|
+
return refuse("neither `rightkit` nor the managed cargo shim (cargo-guard.mjs) is first on PATH; plain cargo is never used");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const loadFromRoot = (root, file) => {
|
|
386
|
+
const target = path.join(root, "scripts", "ci", file);
|
|
387
|
+
if (!fs.existsSync(target)) refuse(`${root} has no scripts/ci/${file}; local qualification runs the repository's own CI scripts (the ones its promotion workflow runs), so a repository without them cannot qualify crates this way`);
|
|
388
|
+
return import(pathToFileURL(target).href);
|
|
389
|
+
};
|
|
390
|
+
/** The qualifying checkout's own right-release CLI: the source layout, else the installed package; the source path when neither exists (the run then reports it). */
|
|
391
|
+
const cliOf = (root) => {
|
|
392
|
+
for (const rel of [["packages", "release", "cli", "right-release.mjs"], ["node_modules", "@rightkit", "release", "cli", "right-release.mjs"]]) if (fs.existsSync(path.join(root, ...rel))) return path.join(root, ...rel);
|
|
393
|
+
return path.join(root, "packages", "release", "cli", "right-release.mjs");
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/** The deterministic scans over the units' scope (class A then class B), recorded the way scan-receipt.mjs verifies them. */
|
|
397
|
+
async function localScanRecord({ root, commit, crates, jobs, scratch, deps, log }) {
|
|
398
|
+
const modules = await (deps.loadScanModules ?? ((workspace) => loadFromRoot(workspace, "scan-receipt.mjs")))(root);
|
|
399
|
+
const run = deps.run ?? spawnSync;
|
|
400
|
+
const exportRoot = fs.mkdtempSync(path.join(scratch, "export-"));
|
|
401
|
+
try {
|
|
402
|
+
const tarFile = path.join(exportRoot, "head.tar");
|
|
403
|
+
const archived = run("git", ["archive", "--format=tar", "--prefix=rightkit/", "-o", tarFile, commit], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
404
|
+
const extracted = archived.status === 0 ? run("tar", ["-xf", "head.tar"], { cwd: exportRoot, encoding: "utf8", windowsHide: true }) : archived;
|
|
405
|
+
if (extracted.status !== 0) refuse(`cannot export ${commit.slice(0, 12)} for the content scans: ${String(extracted.stderr ?? "").trim()}`);
|
|
406
|
+
const repo = path.join(exportRoot, "rightkit");
|
|
407
|
+
const tracked = String((deps.git ?? defaultGit)(["ls-files", "-z"], root) ?? "").split("\0").filter(Boolean);
|
|
408
|
+
const unitDirs = crates.map((name) => `crates/${name}/`);
|
|
409
|
+
// Workspace-level scans (lock, deny, audit, workflows, YAML) plus every scan of the units; other crates and npm packages stay out of scope.
|
|
410
|
+
const files = tracked.filter((file) => (!file.startsWith("crates/") && !file.startsWith("packages/")) || unitDirs.some((dir) => file.startsWith(dir)));
|
|
411
|
+
const crateFeatures = (deps.readJson ?? defaultReadJson)(path.join(repo, "ci", "gate.config.json"))?.rust ?? {};
|
|
412
|
+
const plan = (deps.planScans ?? planScans)({ changed: files, contentRoot: repo, liveRoot: root, includeTests: false, localCompile: true, cargoCommand: detectCargoCommand(deps), crateFeatures, readBytes: (file) => fs.readFileSync(file) });
|
|
413
|
+
const observedTools = modules.observeToolVersions();
|
|
414
|
+
log(`[local-qualify] scans: ${plan.scans.length} planned over ${files.length} files (class A, then class B through the broker)`);
|
|
415
|
+
const result = await (deps.runScans ?? runScans)(plan, { classes: ["A", "B"], jobs: Math.max(1, Number(jobs ?? 4) || 4), onResult: (row, progress) => log(`[local-qualify] scan [${progress.done}/${progress.total}] ${row.status.padEnd(7)} ${row.id}`) });
|
|
416
|
+
return modules.buildLocalScanRecord({ result, planned: plan.scans, sourceCommit: commit, headCommit: commit, cargoLockSha256: sha256(fs.readFileSync(path.join(root, "Cargo.lock"))), observedTools, units: crates, files: files.length });
|
|
417
|
+
} finally { fs.rmSync(exportRoot, { recursive: true, force: true }); }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Qualify every unit on THIS host and write a verified bundle into `out`: host-result.json, one directory per unit with the
|
|
422
|
+
* packed .crate and the gate evidence (from `publish cargo --local --qualify-only`), and on the canonical host the upload
|
|
423
|
+
* metadata. Any failure refuses the whole host: a partial bundle is never a candidate.
|
|
424
|
+
*/
|
|
425
|
+
/** The repository a host measures runtime proof for: the driver passes it (--repository) for any repository but RightKit; otherwise it is the host's own origin remote. */
|
|
426
|
+
function repositoryOf({ git, root }) {
|
|
427
|
+
const repository = repositoryFromOrigin(git(["remote", "get-url", "origin"], root));
|
|
428
|
+
return repository ?? refuse("cannot derive owner/name from the origin remote; pass --repository owner/name");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export async function qualifyHost(options, deps = {}) {
|
|
432
|
+
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
433
|
+
const { root, id, commit, crates, exclude = [], allowIgnoredUnits = [], jobs, concurrency = 2, canonical = false, runtimeRepeats = 3, waitIdleMin = 30 } = options;
|
|
434
|
+
const out = path.resolve(options.out);
|
|
435
|
+
const platform = deps.platform ?? process.platform;
|
|
436
|
+
const hostKey = localHostKey(platform);
|
|
437
|
+
const git = deps.git ?? defaultGit;
|
|
438
|
+
const head = git(["rev-parse", "HEAD"], root);
|
|
439
|
+
if (head !== commit) refuse(`${hostKey}: HEAD is ${String(head).slice(0, 12)}, not the candidate commit ${commit.slice(0, 12)}; bring this checkout to it first`);
|
|
440
|
+
const tree = git(["rev-parse", "HEAD^{tree}"], root);
|
|
441
|
+
const dirty = git(["status", "--porcelain"], root);
|
|
442
|
+
if (!SHA40.test(tree ?? "") || dirty !== "") refuse(`${hostKey}: the checkout is not clean at ${commit.slice(0, 12)}${dirty ? `:\n${dirty}` : ""}`);
|
|
443
|
+
const broker = brokerIdentity(deps);
|
|
444
|
+
if (fs.existsSync(out)) refuse(`host output already exists: ${out}`);
|
|
445
|
+
fs.mkdirSync(out, { recursive: true });
|
|
446
|
+
const readJson = deps.readJson ?? defaultReadJson;
|
|
447
|
+
const unitInfo = crates.map((name) => { const pkg = (deps.readPackage ?? readCargoPackage)(path.join(root, "crates", name)); return { name, version: pkg.version }; });
|
|
448
|
+
const archiveScan = await (deps.loadArchiveScan ?? ((workspace) => loadFromRoot(workspace, "archive-secret-scan.mjs")))(root);
|
|
449
|
+
const scans = await localScanRecord({ root, commit, crates, jobs, scratch: out, deps: { ...deps, git, readJson }, log });
|
|
450
|
+
if (scans.passed !== true) refuse(`${hostKey}: deterministic scans did not pass:\n - ${scans.problems.join("\n - ")}`);
|
|
451
|
+
// Async so a long unit never stops this process draining the other host's output.
|
|
452
|
+
const run = deps.runAsync ?? ((command, args, options) => spawnCollect(command, args, options));
|
|
453
|
+
const cli = cliOf(root);
|
|
454
|
+
const rows = new Array(unitInfo.length);
|
|
455
|
+
const queue = unitInfo.map((unit, index) => ({ unit, index }));
|
|
456
|
+
const failures = [];
|
|
457
|
+
const worker = async () => {
|
|
458
|
+
while (!failures.length && queue.length) {
|
|
459
|
+
const { unit, index } = queue.shift();
|
|
460
|
+
const file = `${unit.name}-${unit.version}.crate`;
|
|
461
|
+
try {
|
|
462
|
+
if (exclude.includes(unit.name)) { rows[index] = { name: unit.name, version: unit.version, file, sha256: null, bytesVerified: false, status: "allowed-exclusion", executed: 0, ignored: 0, skipped: 0, steps: [] }; continue; }
|
|
463
|
+
const bundle = path.join(out, unit.name);
|
|
464
|
+
const argv = [cli, "publish", "cargo", "--crate", unit.name, "--local", "--qualify-only", "--bundle", bundle, "--workspace", root, ...(allowIgnoredUnits.includes(unit.name) ? ["--allow-ignored"] : []), ...(jobs === undefined ? [] : ["--jobs", String(jobs)])];
|
|
465
|
+
log(`[local-qualify] ${hostKey}: qualify ${unit.name}@${unit.version}`);
|
|
466
|
+
const result = await run(process.execPath, argv, { cwd: root, encoding: "utf8", windowsHide: true, maxBuffer: 512 * 1024 * 1024, timeout: HOST_TIMEOUT_MS, env: process.env });
|
|
467
|
+
if (result.error || result.status !== 0) refuse(`${unit.name}: qualify-only failed (${result.error?.message ?? `exit ${result.status}`})\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
|
|
468
|
+
const crateFile = path.join(bundle, file);
|
|
469
|
+
const bytes = fs.readFileSync(crateFile);
|
|
470
|
+
const evidence = JSON.parse(fs.readFileSync(path.join(bundle, "gate.json"), "utf8"));
|
|
471
|
+
const sha = sha256(bytes);
|
|
472
|
+
const row = evidence.runs?.[0];
|
|
473
|
+
if (evidence.artifactSha256 !== sha || evidence.commit !== commit || evidence.name !== unit.name || evidence.version !== unit.version || evidence.runs?.length !== 1 || row?.commit !== commit) refuse(`${unit.name}: the gate evidence does not describe the bundled archive at ${commit.slice(0, 12)}`);
|
|
474
|
+
const scanned = await archiveScan.scanArchiveForSecrets({ file: crateFile, registry: "cargo", name: unit.name, version: unit.version, scratchRoot: out });
|
|
475
|
+
if (canonical) {
|
|
476
|
+
const metadata = await (deps.uploadMetadata ?? uploadMetadataFor)({ root, run: deps.runSync ?? spawnSync, crateFile, name: unit.name, version: unit.version, deps });
|
|
477
|
+
fs.writeFileSync(path.join(bundle, `${unit.name}-${unit.version}.upload.json`), `${JSON.stringify(metadata)}\n`);
|
|
478
|
+
}
|
|
479
|
+
const ignored = row.ignored ?? 0; const skipped = row.skipped ?? 0;
|
|
480
|
+
rows[index] = { name: unit.name, version: unit.version, file, sha256: sha, bytesVerified: true, archiveScan: scanned, status: ignored > 0 || skipped > 0 ? "allowed-skips" : "pass", executed: row.passed, ignored, skipped, steps: row.steps ?? [], evidence };
|
|
481
|
+
} catch (error) { failures.push(`${unit.name}: ${error.message}`); }
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, unitInfo.length) }, worker));
|
|
485
|
+
if (failures.length) refuse(`${hostKey}: qualification failed:\n - ${failures.join("\n - ")}`);
|
|
486
|
+
// Runtime owners: measured AFTER every other unit finished, one at a time, because a quiet host is the precondition. The host
|
|
487
|
+
// proves each on its own broker; nothing here can be satisfied by the other host (admission needs both).
|
|
488
|
+
let runtime;
|
|
489
|
+
const runtimeRows = rows.filter((row) => RUNTIME_BUDGET_CRATES.includes(row.name));
|
|
490
|
+
if (runtimeRows.length) {
|
|
491
|
+
const excluded = runtimeRows.filter((row) => row.status === "allowed-exclusion").map((row) => row.name);
|
|
492
|
+
if (excluded.length) refuse(`${hostKey}: ${excluded.join(", ")} ${excluded.length > 1 ? "are runtime owners" : "is a runtime owner"}; the runtime proof needs both native hosts, so it cannot be excluded from one`);
|
|
493
|
+
runtime = { schema: 1, kind: "local-runtime", crates: {} };
|
|
494
|
+
for (const row of runtimeRows) {
|
|
495
|
+
try {
|
|
496
|
+
runtime.crates[row.name] = await (deps.measureRuntime ?? measureRuntimeCrate)({ root, commit, crate: row.name, version: row.version, archiveSha256: row.sha256, candidateId: id, broker, cargoCommand: detectCargoCommand(deps), repository: options.repository ?? repositoryOf({ git, root }), scratch: path.join(path.dirname(out), `${path.basename(out)}-runtime-scratch`), repeats: runtimeRepeats, waitIdleMs: waitIdleMin * 60_000, log }, deps.runtime ?? {});
|
|
497
|
+
} catch (error) { refuse(`${hostKey}: runtime proof for ${row.name} refused:\n - ${String(error.message).split("\n").join("\n ")}`); }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const result = { schema: 1, kind: "local-host-result", host: HOSTS[hostKey].name, hostOs: platform, arch: deps.arch ?? process.arch, candidateId: id, commit, tree, broker, qualifiedAt: (deps.now ?? (() => new Date().toISOString()))(), scans, units: rows, ...(runtime ? { runtime } : {}) };
|
|
501
|
+
fs.writeFileSync(path.join(out, "host-result.json"), `${JSON.stringify(result, null, 2)}\n`);
|
|
502
|
+
writeBundleManifest(out);
|
|
503
|
+
log(`[local-qualify] ${hostKey}: ${rows.length} unit(s) qualified; bundle ${out}`);
|
|
504
|
+
return { out, result };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The crates.io upload metadata of a packed crate: cargo metadata of its unpacked Cargo.toml, exactly as publish.mjs packs it. */
|
|
508
|
+
async function uploadMetadataFor({ root, run, crateFile, name, version, deps }) {
|
|
509
|
+
const scratch = fs.mkdtempSync(path.join(path.dirname(path.dirname(crateFile)), "meta-"));
|
|
510
|
+
try {
|
|
511
|
+
const unpacked = run("tar", ["-xzf", path.basename(crateFile), "-C", scratch.replaceAll("\\", "/")], { cwd: path.dirname(crateFile), encoding: "utf8", windowsHide: true });
|
|
512
|
+
if (unpacked.status !== 0) refuse(`cannot unpack ${path.basename(crateFile)}`);
|
|
513
|
+
const inner = path.join(scratch, `${name}-${version}`);
|
|
514
|
+
const metadata = await (deps.cargoMetadata ?? (async (manifest) => {
|
|
515
|
+
const { runCargoReadOnly } = await import("./publish-cargo.mjs");
|
|
516
|
+
const { runCargoSync } = await import("./managed-cargo.mjs");
|
|
517
|
+
const result = await runCargoReadOnly(["metadata", "--format-version", "1", "--no-deps", "--manifest-path", manifest], { cwd: root, encoding: "utf8", maxBuffer: 512 * 1024 * 1024, windowsHide: true }, (_command, args, settings) => runCargoSync(args, settings), () => {});
|
|
518
|
+
if (result.error || result.status !== 0) refuse(`cargo metadata failed for ${name}: ${tail(result.stderr)}`);
|
|
519
|
+
return JSON.parse(result.stdout);
|
|
520
|
+
}))(path.join(inner, "Cargo.toml"));
|
|
521
|
+
const pkg = metadata.packages.find((item) => item.name === name && item.version === version);
|
|
522
|
+
if (!pkg) refuse(`${path.basename(crateFile)}: packaged Cargo.toml does not describe ${name}@${version}`);
|
|
523
|
+
const { buildCrateUploadMetadata } = await (deps.loadPublish ?? ((workspace) => loadFromRoot(workspace, "publish.mjs")))(root);
|
|
524
|
+
return buildCrateUploadMetadata(pkg, pkg.readme ? fs.readFileSync(path.join(inner, pkg.readme), "utf8") : null);
|
|
525
|
+
} finally { fs.rmSync(scratch, { recursive: true, force: true }); }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---- seal -------------------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
/** A stored (uncompressed) ZIP with fixed timestamps and flat names, so the same inputs give the same bytes. */
|
|
531
|
+
export function writeStoredZip(entries) {
|
|
532
|
+
const locals = []; const central = []; let offset = 0;
|
|
533
|
+
for (const { name, data } of entries) {
|
|
534
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/.test(name)) refuse(`unsafe ZIP entry name ${JSON.stringify(name)}`);
|
|
535
|
+
const nameBytes = Buffer.from(name); const crc = zlib.crc32(data) >>> 0;
|
|
536
|
+
const local = Buffer.alloc(30);
|
|
537
|
+
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0, 6); local.writeUInt16LE(0, 8); local.writeUInt16LE(0, 10); local.writeUInt16LE(0x0021, 12);
|
|
538
|
+
local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(nameBytes.length, 26);
|
|
539
|
+
const header = Buffer.alloc(46);
|
|
540
|
+
header.writeUInt32LE(0x02014b50, 0); header.writeUInt16LE((3 << 8) | 20, 4); header.writeUInt16LE(20, 6); header.writeUInt16LE(0x0021, 14);
|
|
541
|
+
header.writeUInt32LE(crc, 16); header.writeUInt32LE(data.length, 20); header.writeUInt32LE(data.length, 24); header.writeUInt16LE(nameBytes.length, 28);
|
|
542
|
+
header.writeUInt32LE(((0o100644) << 16) >>> 0, 38); header.writeUInt32LE(offset, 42);
|
|
543
|
+
locals.push(local, nameBytes, data); central.push(header, nameBytes);
|
|
544
|
+
offset += local.length + nameBytes.length + data.length;
|
|
545
|
+
}
|
|
546
|
+
const directory = Buffer.concat(central); const end = Buffer.alloc(22);
|
|
547
|
+
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16);
|
|
548
|
+
return Buffer.concat([...locals, directory, end]);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The sealed manifest (loadSealed shape) over the canonical host's archives and upload metadata, in dependency order. */
|
|
552
|
+
export function buildManifest({ id, commit, units, repository = REPOSITORY }) {
|
|
553
|
+
return {
|
|
554
|
+
schema: 1, registry: "crates", commit, repository, source: "local", candidateId: id,
|
|
555
|
+
units: units.map(({ name, version, dir, crate, metadata }) => ({ name, version, dir, file: `${name}-${version}.crate`, size: crate.length, sha256: sha256(crate), metaFile: `${name}-${version}.upload.json`, metaSha256: sha256(metadata) })),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Map one host's raw result into the `local-verify` receipt checkLocalReceipts reads. */
|
|
560
|
+
export function buildReceipt({ result, candidateId, manifestSha256, allow }) {
|
|
561
|
+
return {
|
|
562
|
+
schema: 1, kind: "local-verify", host: result.host, hostOs: result.hostOs, arch: result.arch, registry: "crates", candidateId,
|
|
563
|
+
commit: result.commit, tree: result.tree, manifestSha256, broker: result.broker, qualifiedAt: result.qualifiedAt,
|
|
564
|
+
allowHostExclusion: [...allow.hostExclusion], allowNoTest: [...allow.noTest], allowIgnored: [...allow.ignored], allowMissingAssets: [...allow.missingAssets],
|
|
565
|
+
scans: result.scans,
|
|
566
|
+
units: result.units.map((row) => ({ ...row })),
|
|
567
|
+
...(result.runtime ? { runtime: bindRuntimeManifest(result.runtime, manifestSha256) } : {}),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Seal the candidate from the pulled bundles. `bundles` maps host key to a verified directory. Refuses when a host's commit,
|
|
573
|
+
* tree or archive bytes differ from the canonical host's: every host must have qualified the bytes that will be uploaded.
|
|
574
|
+
* Returns the files to write and the candidate JSON, and runs the same loadSealed/checkLocalReceipts the promotion runs.
|
|
575
|
+
*/
|
|
576
|
+
export function sealCandidate({ id, commit, tree, plan, bundles, readFile = fs.readFileSync, stage, repository = REPOSITORY }) {
|
|
577
|
+
const results = {};
|
|
578
|
+
for (const [key, dir] of Object.entries(bundles)) {
|
|
579
|
+
const result = JSON.parse(readFile(path.join(dir, "host-result.json"), "utf8"));
|
|
580
|
+
if (result.kind !== "local-host-result" || result.commit !== commit || result.tree !== tree || result.candidateId !== id || result.hostOs !== HOSTS[key].os || result.host !== HOSTS[key].name) refuse(`${key}: the host result is not for ${id} at ${commit.slice(0, 12)} on ${HOSTS[key].name}`);
|
|
581
|
+
results[key] = result;
|
|
582
|
+
}
|
|
583
|
+
const canonical = bundles[CANONICAL_HOST];
|
|
584
|
+
if (!canonical) refuse("the macOS host is the canonical packer; --hosts must include mac to cut a candidate");
|
|
585
|
+
const units = plan.units.map((unit) => {
|
|
586
|
+
const file = `${unit.name}-${unit.version}.crate`;
|
|
587
|
+
const crate = readFile(path.join(canonical, unit.name, file));
|
|
588
|
+
const metadata = readFile(path.join(canonical, unit.name, `${unit.name}-${unit.version}.upload.json`));
|
|
589
|
+
for (const [key, result] of Object.entries(results)) {
|
|
590
|
+
const row = result.units.find((item) => item.name === unit.name);
|
|
591
|
+
if (!row) refuse(`${key}: no result for ${unit.name}`);
|
|
592
|
+
if (row.status !== "allowed-exclusion" && row.sha256 !== sha256(crate)) refuse(`${unit.name}: ${key} packed ${String(row.sha256).slice(0, 12)} but the canonical macOS archive is ${sha256(crate).slice(0, 12)}; the hosts qualified different bytes, so no candidate is cut (use publish.yml for this unit)`);
|
|
593
|
+
}
|
|
594
|
+
return { name: unit.name, version: unit.version, dir: unit.dir, crate, metadata };
|
|
595
|
+
});
|
|
596
|
+
const allow = { ...plan.allow, ignored: plan.units.filter((unit) => Object.values(results).some((result) => (result.units.find((row) => row.name === unit.name)?.ignored ?? 0) > 0)).map((unit) => unit.name) };
|
|
597
|
+
const manifest = buildManifest({ id, commit, units, repository });
|
|
598
|
+
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
599
|
+
const manifestSha256 = sha256(manifestBytes);
|
|
600
|
+
const files = [{ name: "manifest.json", data: manifestBytes }, ...units.flatMap((unit) => [{ name: `${unit.name}-${unit.version}.crate`, data: unit.crate }, { name: `${unit.name}-${unit.version}.upload.json`, data: unit.metadata }])];
|
|
601
|
+
const receipts = Object.fromEntries(Object.keys(results).map((key) => [HOSTS[key].name, Buffer.from(`${JSON.stringify(buildReceipt({ result: results[key], candidateId: id, manifestSha256, allow }), null, 2)}\n`)]));
|
|
602
|
+
// Same checks the promotion repeats, before anything is uploaded.
|
|
603
|
+
stage({ files, receipts, allow });
|
|
604
|
+
return { manifest, manifestSha256, files, receipts, allow, units, zip: writeStoredZip(files) };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Candidate JSON and the approval skeleton. reviewer/reviewRef are null on purpose: null fails the approval loader until a reviewer fills it. */
|
|
608
|
+
export function candidateDocument({ id, commit, units, allow, names, hashes, ci }) {
|
|
609
|
+
const candidate = { id, source: "local", sourceSha: commit, registry: "crates", units: units.map((unit) => unit.name), allow, release: { tag: `candidate-${id}` }, assets: Object.fromEntries(Object.entries(names).map(([kind, name]) => [kind, { name, sha256: hashes[kind] }])), ...(ci ? { ci } : {}) };
|
|
610
|
+
const artifacts = units.map((unit) => ({ registry: "crates", name: unit.name, version: unit.version, sha256: sha256(unit.crate), reviewer: null, reviewRef: null }));
|
|
611
|
+
return { candidate, approval: { candidates: [candidate], artifacts } };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---- retired raw GitHub dispatch --------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
export const CI_WORKFLOW = "publish.yml";
|
|
617
|
+
/** Compatibility exports refuse rather than bypass RustRight's CI entry. */
|
|
618
|
+
export function ciDispatchArgs() { refuse(CI_DISPATCH_REFUSAL); }
|
|
619
|
+
export async function dispatchCi() { refuse(CI_DISPATCH_REFUSAL); }
|
|
620
|
+
|
|
621
|
+
// ---- driver -----------------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* `right-release qualify cargo ...`. `deps` injects every effect (git, spawn, ssh, gh, file reads, the host run) so a test can
|
|
625
|
+
* observe the plan: which commands run on which host, that both hosts start before either finishes, what the release gets.
|
|
626
|
+
*/
|
|
627
|
+
export async function runQualify(argv, deps = {}) {
|
|
628
|
+
if (argv.includes("--with-ci")) refuse(CI_DISPATCH_REFUSAL);
|
|
629
|
+
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
630
|
+
const platform = deps.platform ?? process.platform;
|
|
631
|
+
const parsed = parseQualifyArgs(argv, platform);
|
|
632
|
+
const here = localHostKey(platform);
|
|
633
|
+
const root = deps.root ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
634
|
+
const git = deps.git ?? defaultGit;
|
|
635
|
+
const { head, tree } = checkoutState({ git, root });
|
|
636
|
+
const config = loadQualifyConfig({ root, git, readText: deps.readText, exists: deps.exists });
|
|
637
|
+
const roots = Object.fromEntries(parsed.hosts.map((key) => [key, hostRoot({ key, flags: parsed.roots, config })]));
|
|
638
|
+
const reviewed = await (deps.loadReviewed ?? ((workspace) => loadFromRoot(workspace, "reviewed-candidate.mjs")))(root);
|
|
639
|
+
const plan = planUnits({ root, crates: parsed.crates, readJson: deps.readJson, readPackage: deps.readPackage, dependsOn: deps.dependsOn, runtimeCoverage: Object.keys(reviewed.RUNTIME_PROOF_WORKLOADS), withCi: parsed.withCi });
|
|
640
|
+
const id = parsed.id ?? candidateIdFor({ crates: parsed.crates, version: plan.units[0].version, head });
|
|
641
|
+
const outDir = path.join(parsed.outRoot, id);
|
|
642
|
+
const exec = deps.exec ?? ((command, args, options) => spawnCollect(command, args, { encoding: "utf8", windowsHide: true, maxBuffer: 1024 * 1024 * 1024, timeout: HOST_TIMEOUT_MS, ...options }));
|
|
643
|
+
const remoteNode = deps.remoteNode ?? process.env.RK_LOCALQ_REMOTE_NODE ?? "node";
|
|
644
|
+
const script = deps.script ?? remoteScriptPath(root);
|
|
645
|
+
log(`[qualify] ${id}: ${plan.units.map((unit) => `${unit.name}@${unit.version}`).join(", ")} at ${head.slice(0, 12)} on ${parsed.hosts.join(" + ")} (${config.repository})`);
|
|
646
|
+
|
|
647
|
+
const runHost = async (key) => {
|
|
648
|
+
const host = HOSTS[key];
|
|
649
|
+
const hostOut = `${key === here ? outDir : `${host.scratch}/${id}`}/${key}`;
|
|
650
|
+
const args = hostArgs({ root: roots[key], id, commit: head, crates: parsed.crates, out: hostOut, exclude: plan.units.filter((unit) => unit.excludedOn.includes(key)).map((unit) => unit.name), allowIgnoredUnits: parsed.allowIgnoredUnits, jobs: parsed.jobs, concurrency: parsed.concurrency, runtimeRepeats: parsed.runtimeRepeats, waitIdleMin: parsed.waitIdleMin, canonical: key === CANONICAL_HOST, ...(config.repository === REPOSITORY ? {} : { repository: config.repository }) });
|
|
651
|
+
if (key === here) {
|
|
652
|
+
const options = parseHostArgs(args.slice(1));
|
|
653
|
+
const { out } = await (deps.qualifyHost ?? qualifyHost)({ ...options, root }, deps.host ?? {});
|
|
654
|
+
return out;
|
|
655
|
+
}
|
|
656
|
+
const reach = reachArgs(here, remoteCommand({ root: roots[key], args, node: remoteNode, script }));
|
|
657
|
+
const ran = await exec(reach.command, reach.args, {});
|
|
658
|
+
if (ran.error || ran.status !== 0) refuse(`${key}: remote qualification failed (${ran.error?.message ?? `exit ${ran.status}`})\n${tail(`${ran.stdout ?? ""}\n${ran.stderr ?? ""}`)}`);
|
|
659
|
+
const pull = reachArgs(here, remoteCommand({ root: roots[key], args: ["export", "--dir", hostOut], node: remoteNode, script }));
|
|
660
|
+
const exported = await exec(pull.command, pull.args, {});
|
|
661
|
+
if (exported.error || exported.status !== 0) refuse(`${key}: cannot pull the bundle (${exported.error?.message ?? `exit ${exported.status}`})\n${tail(exported.stderr)}`);
|
|
662
|
+
return importBundle(exported.stdout, path.join(outDir, key));
|
|
663
|
+
};
|
|
664
|
+
// Both hosts run at once; one failing does not hide the other's result.
|
|
665
|
+
const hostRuns = parsed.hosts.map(runHost);
|
|
666
|
+
const labels = parsed.hosts;
|
|
667
|
+
const settled = await Promise.allSettled(hostRuns);
|
|
668
|
+
const failed = settled.map((row, index) => {
|
|
669
|
+
if (row.status !== "rejected") return null;
|
|
670
|
+
const message = String(row.reason?.message ?? row.reason);
|
|
671
|
+
return message.startsWith(`${labels[index]}:`) ? message : `${labels[index]}: ${message}`;
|
|
672
|
+
}).filter(Boolean);
|
|
673
|
+
if (failed.length) refuse(`qualification failed:\n${failed.join("\n")}`);
|
|
674
|
+
const bundles = Object.fromEntries(parsed.hosts.map((key, index) => [key, settled[index].value]));
|
|
675
|
+
const ci = null;
|
|
676
|
+
for (const dir of Object.values(bundles)) verifyBundle(dir);
|
|
677
|
+
if (!(parsed.hosts.includes("win") && parsed.hosts.includes("mac"))) {
|
|
678
|
+
log(`[qualify] ${id}: only ${parsed.hosts.join(", ")} ran; bundles are in ${outDir}. A candidate needs both hosts, so none is cut.`);
|
|
679
|
+
return { id, outDir, bundles, candidate: null };
|
|
680
|
+
}
|
|
681
|
+
const upload = await (deps.loadUpload ?? ((workspace) => loadFromRoot(workspace, "upload.mjs")))(root);
|
|
682
|
+
const assetsDir = path.join(outDir, "assets"); const stageDir = path.join(outDir, "stage");
|
|
683
|
+
// The staged layout is the one promotion rebuilds from the release assets; the same loaders judge it before anything is uploaded.
|
|
684
|
+
const stage = ({ files, receipts, allow }) => {
|
|
685
|
+
fs.mkdirSync(path.join(stageDir, "packed"), { recursive: true });
|
|
686
|
+
for (const { name, data } of files) fs.writeFileSync(path.join(stageDir, "packed", name), data, { flag: "wx" });
|
|
687
|
+
for (const [host, data] of Object.entries(receipts)) { fs.mkdirSync(path.join(stageDir, "receipts", host), { recursive: true }); fs.writeFileSync(path.join(stageDir, "receipts", host, `receipt-${host}.json`), data, { flag: "wx" }); }
|
|
688
|
+
const staged = upload.loadSealed({ packDir: path.join(stageDir, "packed"), registry: "crates", unitsText: plan.units.map((unit) => unit.name).join(" ") });
|
|
689
|
+
const problems = [...staged.problems];
|
|
690
|
+
// A Linux-required unit has no Linux receipt here: the pinned CI run is its Linux proof (promotion requires that run to succeed).
|
|
691
|
+
if (staged.manifest) problems.push(...upload.checkLocalReceipts({ manifest: staged.manifest, manifestSha256: staged.manifestSha256, receiptsDir: path.join(stageDir, "receipts"), allow, candidate: { id }, ...(ci ? { linuxRequired: [] } : {}) }));
|
|
692
|
+
if (problems.length) refuse(`the sealed candidate would be refused at promotion:\n - ${problems.join("\n - ")}`);
|
|
693
|
+
};
|
|
694
|
+
const sealed = sealCandidate({ id, commit: head, tree, plan, bundles, stage, repository: config.repository });
|
|
695
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
696
|
+
const names = { packed: reviewed.localAssetName(id, "packed"), windows: reviewed.localAssetName(id, "windows"), macos: reviewed.localAssetName(id, "macos") };
|
|
697
|
+
const contents = { packed: sealed.zip, windows: sealed.receipts.windows, macos: sealed.receipts.macos };
|
|
698
|
+
const hashes = {};
|
|
699
|
+
for (const [kind, name] of Object.entries(names)) { fs.writeFileSync(path.join(assetsDir, name), contents[kind], { flag: "wx" }); hashes[kind] = sha256(contents[kind]); }
|
|
700
|
+
const document = candidateDocument({ id, commit: head, units: sealed.units, allow: sealed.allow, names, hashes, ci });
|
|
701
|
+
reviewed.validateCandidate(structuredClone(document.candidate));
|
|
702
|
+
fs.writeFileSync(path.join(outDir, "candidate.json"), `${JSON.stringify(document, null, 2)}\n`, { flag: "wx" });
|
|
703
|
+
if (parsed.release) {
|
|
704
|
+
const tag = document.candidate.release.tag;
|
|
705
|
+
const created = (deps.spawn ?? spawnSync)("gh", ["release", "create", tag, "--repo", config.repository, "--prerelease", "--target", head, "--title", `Local qualification ${id}`, "--notes", `Locally qualified candidate ${id} at ${head}. Not a release; promoted only by ${config.promotionWorkflow} after its pins are committed.`, ...Object.values(names).map((name) => path.join(assetsDir, name))], { encoding: "utf8", windowsHide: true });
|
|
706
|
+
if (created.error || created.status !== 0) refuse(`gh release create failed: ${tail(`${created.stdout ?? ""}\n${created.stderr ?? ""}`)}`);
|
|
707
|
+
const view = (deps.spawn ?? spawnSync)("gh", ["api", `repos/${config.repository}/releases/tags/${tag}`], { encoding: "utf8", windowsHide: true });
|
|
708
|
+
let release; try { release = JSON.parse(view.stdout); } catch { refuse("cannot read the created prerelease back"); }
|
|
709
|
+
const have = new Map((release.assets ?? []).map((asset) => [asset.name, asset.size]));
|
|
710
|
+
if (release.prerelease !== true || Object.values(names).some((name) => have.get(name) !== fs.statSync(path.join(assetsDir, name)).size) || have.size !== Object.keys(names).length) refuse(`the prerelease ${tag} does not hold exactly the three assets`);
|
|
711
|
+
log(`[qualify] prerelease ${tag} created and read back`);
|
|
712
|
+
}
|
|
713
|
+
log(`[qualify] candidate ${id} sealed. Add this to ${config.approvedArtifacts} (a reviewer sets reviewer and reviewRef, adds the two publish.test.mjs lines, commits; ${config.promotionWorkflow} then promotes it; nothing was written for you):`);
|
|
714
|
+
log(JSON.stringify(document, null, 2));
|
|
715
|
+
if (ci) log(`[qualify] ci: run ${ci.runId} is pinned in the candidate; promotion admits it as clean-room proof when it lands (${plan.ciRequired?.length ? `its success is REQUIRED for ${plan.ciRequired.join(", ")}` : "pending proceeds on the local bytes, a failure needs allow.ciFailure"}). Watch it: gh run watch ${ci.runId} -R ${config.repository}`);
|
|
716
|
+
return { id, outDir, bundles, candidate: document.candidate, document };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const realOrSelf = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
|
|
720
|
+
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
721
|
+
const [first, ...rest] = process.argv.slice(2);
|
|
722
|
+
const fail = (error) => { process.stderr.write(`right-release qualify: ${error.message}\n`); process.exitCode = 1; };
|
|
723
|
+
try {
|
|
724
|
+
if (first === "host") await qualifyHost(parseHostArgs(rest));
|
|
725
|
+
else if (first === "export") { const dir = rest[0] === "--dir" ? rest[1] : undefined; if (!dir || rest.length !== 2) refuse("usage: local-qualify.mjs export --dir <bundle>"); process.stdout.write(exportBundle(path.resolve(dir))); }
|
|
726
|
+
else await runQualify(process.argv.slice(2));
|
|
727
|
+
} catch (error) { fail(error); }
|
|
728
|
+
}
|