@rightkit/release 0.6.16 → 0.6.21
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 +7 -3
- package/admission-host.mjs +9 -5
- package/app-closure.mjs +9 -3
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +481 -103
- package/bin/rightkit-linux-setup.mjs +15 -0
- package/broker-admission.mjs +184 -0
- package/broker-wire.mjs +15 -3
- package/build-launch.mjs +4 -1
- package/build-ownership.mjs +125 -36
- package/build-release.mjs +39 -7
- package/cargo-contract.mjs +4 -1
- package/cargo-guard.mjs +25 -5
- package/cargo-target-shim.mjs +56 -7
- package/clippy-route.mjs +8 -7
- package/crate-graph.mjs +5 -1
- package/credential-provider.mjs +174 -0
- package/deterministic-scans.mjs +253 -42
- package/development.mjs +8 -0
- package/disk-policy.mjs +2 -2
- package/gate-executor.mjs +5 -3
- package/gate-stages.mjs +34 -3
- package/gradle-lock.mjs +206 -0
- package/heavy-command.mjs +33 -2
- package/linux-artifacts.mjs +2 -1
- package/linux-setup.mjs +106 -0
- package/local-publish.mjs +21 -10
- package/local-qualify.mjs +9 -3
- package/managed-cargo.mjs +54 -18
- package/ownership-lockfiles.mjs +14 -3
- package/ownership.mjs +5 -1
- package/package.json +17 -11
- package/protected-credentials.mjs +13 -5
- package/publication-gate.mjs +9 -4
- package/python-lock.mjs +209 -0
- package/qa-admission-policy.json +32 -0
- package/qa-layout.mjs +229 -0
- package/qa-runner.mjs +156 -0
- package/release-artifact-path.mjs +29 -3
- package/release-config.mjs +1 -1
- package/release-qa-command.mjs +58 -8
- package/release-qa-policy.mjs +5 -3
- package/release-source-identity.mjs +1 -0
- package/release.mjs +10 -2
- package/rightkit-ownership.json +1465 -79
- package/rightkit-registry-resolver.mjs +7 -3
- package/rightkit-versions.json +173 -36
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +5 -1
- package/shell-contract.mjs +1 -1
- package/snapshot-broker.mjs +114 -9
- package/snapshot-check.mjs +6 -1
- package/snapshot-request.mjs +20 -3
- package/snapshot.mjs +11 -4
- package/stage-commands.mjs +2 -1
- package/stage-ledger.mjs +15 -0
- package/stage-sidecar-command.mjs +7 -1
- package/stage-sidecar.mjs +17 -3
- package/suite-signing.mjs +40 -4
- package/swift-lock.mjs +334 -0
- package/test-command.mjs +13 -3
- package/tool-build.mjs +156 -0
- package/unit-gate.mjs +9 -2
- package/windows-identity-plan.mjs +119 -0
- package/windows-identity.mjs +136 -0
package/swift-lock.mjs
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// File-only SwiftPM lockfile check (class A scan `swift-lock`; Adrian 2026-11-01: "the build should not be able to proceed if the lockfile
|
|
2
|
+
// isn't a match"). Like `cargo --locked` and `pnpm --frozen-lockfile`, a committed Package.resolved that disagrees with the declared
|
|
3
|
+
// dependencies must fail before any compile. This module reads committed files and executes nothing: Package.swift is code, so SwiftPM is
|
|
4
|
+
// never run; the `.package(...)` declarations are read literally, and a declaration that cannot be read literally is a finding (never a
|
|
5
|
+
// silent pass). It checks the ROOT package's own declarations against the pins: transitive pins and the pins' own consistency with
|
|
6
|
+
// dependency manifests are SwiftPM's (`--disable-automatic-resolution` in the class B swift-build scan).
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
// ---- versions -------------------------------------------------------------------------------------------------------------
|
|
10
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
11
|
+
export function parseVersion(text) {
|
|
12
|
+
const m = SEMVER.exec(String(text ?? "").trim());
|
|
13
|
+
if (!m) return null;
|
|
14
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ? m[4].split(".") : [], text: String(text).trim() };
|
|
15
|
+
}
|
|
16
|
+
const cmpIdent = (a, b) => {
|
|
17
|
+
const an = /^\d+$/.test(a); const bn = /^\d+$/.test(b);
|
|
18
|
+
if (an && bn) return Math.sign(Number(a) - Number(b));
|
|
19
|
+
if (an) return -1;
|
|
20
|
+
if (bn) return 1;
|
|
21
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
22
|
+
};
|
|
23
|
+
export function compareVersions(a, b) {
|
|
24
|
+
for (const k of ["major", "minor", "patch"]) if (a[k] !== b[k]) return a[k] < b[k] ? -1 : 1;
|
|
25
|
+
if (!a.pre.length && !b.pre.length) return 0;
|
|
26
|
+
if (!a.pre.length) return 1;
|
|
27
|
+
if (!b.pre.length) return -1;
|
|
28
|
+
for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
|
|
29
|
+
if (a.pre[i] === undefined) return -1;
|
|
30
|
+
if (b.pre[i] === undefined) return 1;
|
|
31
|
+
const c = cmpIdent(a.pre[i], b.pre[i]);
|
|
32
|
+
if (c) return c;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
const V = (major, minor, patch) => ({ major, minor, patch, pre: [], text: `${major}.${minor}.${patch}` });
|
|
37
|
+
|
|
38
|
+
/** Requirement as { type, ... } -> readable text. */
|
|
39
|
+
export function requirementText(r) {
|
|
40
|
+
switch (r.type) {
|
|
41
|
+
case "exact": return `exact ${r.version}`;
|
|
42
|
+
case "from": case "upToNextMajor": return `from ${r.version} (up to next major)`;
|
|
43
|
+
case "upToNextMinor": return `from ${r.version} (up to next minor)`;
|
|
44
|
+
case "range": return `${r.lower}${r.closed ? "..." : "..<"}${r.upper}`;
|
|
45
|
+
case "branch": return `branch ${r.branch}`;
|
|
46
|
+
case "revision": return `revision ${r.revision}`;
|
|
47
|
+
default: return JSON.stringify(r);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** null when the pin ({ version, branch, revision }) satisfies the requirement, else the reason. */
|
|
52
|
+
export function requirementProblem(req, pin) {
|
|
53
|
+
const pinned = pin.version ? `version ${pin.version}` : pin.branch ? `branch ${pin.branch}` : pin.revision ? `revision ${pin.revision}` : "no version";
|
|
54
|
+
if (req.type === "branch") return pin.branch === req.branch ? null : `pins ${pinned}, not branch ${req.branch}`;
|
|
55
|
+
if (req.type === "revision") return pin.revision && String(pin.revision).toLowerCase() === req.revision.toLowerCase() ? null : `pins ${pinned}, not revision ${req.revision}`;
|
|
56
|
+
const v = parseVersion(pin.version);
|
|
57
|
+
if (!v) return `pins ${pinned}, which is not a semantic version`;
|
|
58
|
+
const lo = (text) => parseVersion(text);
|
|
59
|
+
switch (req.type) {
|
|
60
|
+
case "exact": { const want = lo(req.version); return compareVersions(v, want) === 0 ? null : `pins version ${pin.version}, not ${req.version}`; }
|
|
61
|
+
case "from": case "upToNextMajor": { const a = lo(req.version); return compareVersions(v, a) >= 0 && compareVersions(v, V(a.major + 1, 0, 0)) < 0 ? null : `pins version ${pin.version}, outside ${req.version} up to next major`; }
|
|
62
|
+
case "upToNextMinor": { const a = lo(req.version); return compareVersions(v, a) >= 0 && compareVersions(v, V(a.major, a.minor + 1, 0)) < 0 ? null : `pins version ${pin.version}, outside ${req.version} up to next minor`; }
|
|
63
|
+
case "range": {
|
|
64
|
+
const a = lo(req.lower); const b = lo(req.upper);
|
|
65
|
+
const upper = compareVersions(v, b);
|
|
66
|
+
return compareVersions(v, a) >= 0 && (req.closed ? upper <= 0 : upper < 0) ? null : `pins version ${pin.version}, outside ${req.lower}${req.closed ? "..." : "..<"}${req.upper}`;
|
|
67
|
+
}
|
|
68
|
+
default: return `has an unknown requirement kind ${req.type}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- locations ------------------------------------------------------------------------------------------------------------
|
|
73
|
+
/** SwiftPM's package identity of a URL or path: its last path component without `.git`, lower-cased. */
|
|
74
|
+
export function identityOf(location) {
|
|
75
|
+
const parts = String(location).replace(/[\\/]+$/, "").split(/[\\/:]/);
|
|
76
|
+
return (parts[parts.length - 1] ?? "").replace(/\.git$/i, "").toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
/** A comparable form of a repository location: no scheme, user, `.git`, trailing slash or case; scp form equals URL form. */
|
|
79
|
+
export function normalizeLocation(location) {
|
|
80
|
+
let t = String(location).trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/^[^@/]+@/, "");
|
|
81
|
+
t = t.replace(/^([^/:]+):(?!\d+\/)/, "$1/").replace(/\/+$/, "").replace(/\.git$/, "").replace(/\/+$/, "");
|
|
82
|
+
return t;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- Package.swift --------------------------------------------------------------------------------------------------------
|
|
86
|
+
/** The text with comments blanked (newlines kept, offsets unchanged); string literals are left whole. */
|
|
87
|
+
function maskComments(text) {
|
|
88
|
+
let out = ""; let i = 0;
|
|
89
|
+
while (i < text.length) {
|
|
90
|
+
const c = text[i]; const two = text.slice(i, i + 2);
|
|
91
|
+
if (text.startsWith('"""', i)) { const end = text.indexOf('"""', i + 3); const stop = end < 0 ? text.length : end + 3; out += text.slice(i, stop); i = stop; continue; }
|
|
92
|
+
if (c === '"') { let j = i + 1; while (j < text.length && text[j] !== '"' && text[j] !== "\n") j += text[j] === "\\" ? 2 : 1; out += text.slice(i, j + 1); i = j + 1; continue; }
|
|
93
|
+
if (two === "//") { while (i < text.length && text[i] !== "\n") { out += " "; i++; } continue; }
|
|
94
|
+
if (two === "/*") {
|
|
95
|
+
let depth = 0;
|
|
96
|
+
while (i < text.length) {
|
|
97
|
+
if (text.startsWith("/*", i)) { depth++; out += " "; i += 2; } else if (text.startsWith("*/", i)) { depth--; out += " "; i += 2; if (!depth) break; } else { out += text[i] === "\n" ? "\n" : " "; i++; }
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
out += c; i++;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Index just past the `)` that closes the `(` at `open`, or -1. Strings are skipped whole. */
|
|
107
|
+
function closeParen(text, open) {
|
|
108
|
+
let depth = 0;
|
|
109
|
+
for (let i = open; i < text.length; i++) {
|
|
110
|
+
const c = text[i];
|
|
111
|
+
if (c === '"') { if (text.startsWith('"""', i)) { const e = text.indexOf('"""', i + 3); if (e < 0) return -1; i = e + 2; continue; } i++; while (i < text.length && text[i] !== '"' && text[i] !== "\n") i += text[i] === "\\" ? 2 : 1; continue; }
|
|
112
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
113
|
+
else if (c === ")" || c === "]" || c === "}") { depth--; if (depth === 0) return i + 1; if (depth < 0) return -1; }
|
|
114
|
+
}
|
|
115
|
+
return -1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function splitArgs(body) {
|
|
119
|
+
const args = []; let depth = 0; let start = 0;
|
|
120
|
+
for (let i = 0; i < body.length; i++) {
|
|
121
|
+
const c = body[i];
|
|
122
|
+
if (c === '"') { i++; while (i < body.length && body[i] !== '"') i += body[i] === "\\" ? 2 : 1; continue; }
|
|
123
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
124
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
125
|
+
else if (c === "," && depth === 0) { args.push(body.slice(start, i)); start = i + 1; }
|
|
126
|
+
}
|
|
127
|
+
const last = body.slice(start);
|
|
128
|
+
if (last.trim() !== "" || args.length) args.push(last);
|
|
129
|
+
return args.map((a) => a.trim());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const STR = '"((?:[^"\\\\\\n]|\\\\[^(\\n])*)"';
|
|
133
|
+
const strOf = (expr) => { const m = new RegExp(`^${STR}$`).exec(expr.trim()); return m ? m[1] : null; };
|
|
134
|
+
const RANGE = new RegExp(`^${STR}\\s*(\\.\\.<|\\.\\.\\.)\\s*${STR}$`);
|
|
135
|
+
const FUNC = new RegExp(`^(?:Package\\.Dependency\\.Requirement)?\\.(upToNextMajor|upToNextMinor|exact|branch|revision)\\(\\s*(?:(?:from|_)\\s*:\\s*)?${STR}\\s*\\)$`);
|
|
136
|
+
|
|
137
|
+
function versionOrNull(s) { return parseVersion(s) ? s : null; }
|
|
138
|
+
|
|
139
|
+
/** Parse one `.package(...)` argument list: { kind: "remote"|"local", url, path, requirement } or { problem }. */
|
|
140
|
+
function parseDependency(args) {
|
|
141
|
+
let url = null; let local = null; let requirement = null;
|
|
142
|
+
const fail = (problem) => ({ problem });
|
|
143
|
+
for (const arg of args) {
|
|
144
|
+
const lab = /^([A-Za-z_]\w*)\s*:\s*([\s\S]*)$/.exec(arg);
|
|
145
|
+
if (lab) {
|
|
146
|
+
const [, label, expr] = lab;
|
|
147
|
+
if (label === "name" || label === "traits") continue;
|
|
148
|
+
if (label === "url") { url = strOf(expr); if (url === null) return fail(`url is not a string literal (${arg.slice(0, 60)})`); continue; }
|
|
149
|
+
if (label === "path") { local = strOf(expr); if (local === null) return fail(`path is not a string literal (${arg.slice(0, 60)})`); continue; }
|
|
150
|
+
if (label === "exact" || label === "from") {
|
|
151
|
+
const v = strOf(expr);
|
|
152
|
+
if (v === null || !versionOrNull(v)) return fail(`${label}: is not a literal semantic version (${expr.slice(0, 60)})`);
|
|
153
|
+
if (requirement) return fail("more than one requirement");
|
|
154
|
+
requirement = label === "exact" ? { type: "exact", version: v } : { type: "from", version: v };
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (label === "branch" || label === "revision") {
|
|
158
|
+
const v = strOf(expr);
|
|
159
|
+
if (v === null || v === "") return fail(`${label}: is not a literal string (${expr.slice(0, 60)})`);
|
|
160
|
+
if (requirement) return fail("more than one requirement");
|
|
161
|
+
requirement = label === "branch" ? { type: "branch", branch: v } : { type: "revision", revision: v };
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
return fail(`unsupported argument label ${label}:`);
|
|
165
|
+
}
|
|
166
|
+
if (requirement) return fail("more than one requirement");
|
|
167
|
+
const range = RANGE.exec(arg);
|
|
168
|
+
if (range) {
|
|
169
|
+
if (!versionOrNull(range[1]) || !versionOrNull(range[3])) return fail(`range bounds are not literal semantic versions (${arg.slice(0, 60)})`);
|
|
170
|
+
requirement = { type: "range", lower: range[1], upper: range[3], closed: range[2] === "..." };
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const fn = FUNC.exec(arg);
|
|
174
|
+
if (fn) {
|
|
175
|
+
const [, kind, v] = fn;
|
|
176
|
+
if ((kind === "exact" || kind === "upToNextMajor" || kind === "upToNextMinor") && !versionOrNull(v)) return fail(`${kind} is not a literal semantic version (${arg.slice(0, 60)})`);
|
|
177
|
+
requirement = kind === "branch" ? { type: "branch", branch: v } : kind === "revision" ? { type: "revision", revision: v } : { type: kind, version: v };
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
return fail(`requirement is not a literal the checker reads (${arg.slice(0, 60)})`);
|
|
181
|
+
}
|
|
182
|
+
if (local !== null && url !== null) return fail("declares both url: and path:");
|
|
183
|
+
if (local !== null) return { kind: "local", path: local };
|
|
184
|
+
if (url === null) return fail("has no url: or path: (registry or computed dependency)");
|
|
185
|
+
if (!requirement) return fail("remote dependency has no requirement");
|
|
186
|
+
return { kind: "remote", url, requirement };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** { deps: [{ line, kind, url, path, identity, requirement }], problems: [{ line, text }] } from Package.swift source, read literally. */
|
|
190
|
+
export function parsePackageSwift(source) {
|
|
191
|
+
const text = maskComments(String(source));
|
|
192
|
+
const deps = []; const problems = [];
|
|
193
|
+
const lineAt = (idx) => text.slice(0, idx).split("\n").length;
|
|
194
|
+
// Lines inside an `#if` ... `#endif` region cannot be evaluated without Swift's compiler conditions.
|
|
195
|
+
const conditional = new Set(); let depth = 0;
|
|
196
|
+
text.split("\n").forEach((l, i) => {
|
|
197
|
+
const t = l.trim();
|
|
198
|
+
if (/^#if\b/.test(t)) depth++;
|
|
199
|
+
if (depth > 0) conditional.add(i + 1);
|
|
200
|
+
if (/^#endif\b/.test(t)) depth = Math.max(0, depth - 1);
|
|
201
|
+
});
|
|
202
|
+
const re = /\.package\s*\(/g; let m;
|
|
203
|
+
while ((m = re.exec(text))) {
|
|
204
|
+
const open = m.index + m[0].length - 1;
|
|
205
|
+
const line = lineAt(m.index);
|
|
206
|
+
const end = closeParen(text, open);
|
|
207
|
+
if (end < 0) { problems.push({ line, text: "unbalanced .package( declaration" }); break; }
|
|
208
|
+
const parsed = parseDependency(splitArgs(text.slice(open + 1, end - 1)));
|
|
209
|
+
re.lastIndex = end;
|
|
210
|
+
if (parsed.problem) { problems.push({ line, text: `.package declaration cannot be read literally: ${parsed.problem}` }); continue; }
|
|
211
|
+
if (conditional.has(line)) { problems.push({ line, text: `.package declaration is inside an #if region, which cannot be evaluated literally (${parsed.kind === "remote" ? parsed.url : parsed.path})` }); continue; }
|
|
212
|
+
deps.push({ line, ...parsed, identity: identityOf(parsed.kind === "remote" ? parsed.url : parsed.path) });
|
|
213
|
+
}
|
|
214
|
+
return { deps, problems };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---- Package.resolved -----------------------------------------------------------------------------------------------------
|
|
218
|
+
/** { pins: [{ identity, location, version, branch, revision }], problems: [text] } for Package.resolved versions 1, 2 and 3. */
|
|
219
|
+
export function parseResolved(source) {
|
|
220
|
+
let doc;
|
|
221
|
+
try { doc = JSON.parse(String(source).replace(/^/, "")); } catch (e) { return { pins: [], problems: [`not valid JSON: ${e.message}`] }; }
|
|
222
|
+
const v = doc?.version;
|
|
223
|
+
let raw;
|
|
224
|
+
if (v === 1) raw = doc?.object?.pins;
|
|
225
|
+
else if (v === 2 || v === 3) raw = doc?.pins;
|
|
226
|
+
else return { pins: [], problems: [`unsupported Package.resolved version ${JSON.stringify(v)} (the checker reads 1, 2 and 3)`] };
|
|
227
|
+
if (!Array.isArray(raw)) return { pins: [], problems: [`Package.resolved version ${v} has no pins array`] };
|
|
228
|
+
const pins = [];
|
|
229
|
+
for (const p of raw) {
|
|
230
|
+
const location = v === 1 ? p?.repositoryURL : p?.location;
|
|
231
|
+
if (typeof location !== "string") return { pins: [], problems: [`a Package.resolved version ${v} pin has no ${v === 1 ? "repositoryURL" : "location"}`] };
|
|
232
|
+
const state = p.state ?? {};
|
|
233
|
+
pins.push({ identity: identityOf(location), location, version: state.version ?? null, branch: state.branch ?? null, revision: state.revision ?? null });
|
|
234
|
+
}
|
|
235
|
+
return { pins, problems: [] };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Findings for the declared remote dependencies against the pins. `label(dep)` prefixes each finding with its file (and line). */
|
|
239
|
+
function matchPins(deps, pins, { label, resolvedFile }) {
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const d of deps) {
|
|
242
|
+
const named = pins.filter((p) => p.identity === d.identity);
|
|
243
|
+
const where = `${label(d)}: package ${d.identity} requires ${requirementText(d.requirement)}`;
|
|
244
|
+
if (!named.length) { out.push(`${where} but ${resolvedFile} has no pin for it`); continue; }
|
|
245
|
+
const pin = named.find((p) => normalizeLocation(p.location) === normalizeLocation(d.url));
|
|
246
|
+
if (!pin) { out.push(`${where} from ${d.url} but ${resolvedFile} pins ${named.map((p) => p.location).join(", ")}`); continue; }
|
|
247
|
+
const why = requirementProblem(d.requirement, pin);
|
|
248
|
+
if (why) out.push(`${where} but ${resolvedFile} ${why}`);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const join = (dir, f) => (dir === "" || dir === "." ? f : `${dir}/${f}`);
|
|
254
|
+
const tryRead = (read, exists, file) => (exists(file) ? { text: read(file) } : null);
|
|
255
|
+
|
|
256
|
+
/** Findings for the Swift package at `dir` (repo-relative, "." for the root). `read`/`exists` take repo-relative paths. */
|
|
257
|
+
export function packageLockProblems({ dir, read, exists }) {
|
|
258
|
+
const manifest = join(dir, "Package.swift"); const resolvedFile = join(dir, "Package.resolved");
|
|
259
|
+
const src = tryRead(read, exists, manifest);
|
|
260
|
+
if (!src) return [];
|
|
261
|
+
const { deps, problems } = parsePackageSwift(src.text);
|
|
262
|
+
const out = problems.map((p) => `${manifest}:${p.line}: ${p.text}`);
|
|
263
|
+
const remote = deps.filter((d) => d.kind === "remote");
|
|
264
|
+
const resolved = tryRead(read, exists, resolvedFile);
|
|
265
|
+
if (!resolved) {
|
|
266
|
+
if (remote.length) out.push(`${manifest}: ${remote.length} remote ${remote.length === 1 ? "dependency" : "dependencies"} (${remote.map((d) => d.identity).join(", ")}) and no ${resolvedFile}; commit the lockfile`);
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
const parsed = parseResolved(resolved.text);
|
|
270
|
+
if (parsed.problems.length) return [...out, ...parsed.problems.map((p) => `${resolvedFile}: ${p}`)];
|
|
271
|
+
out.push(...matchPins(remote, parsed.pins, { label: (d) => `${manifest}:${d.line}`, resolvedFile }));
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- Xcode projects -------------------------------------------------------------------------------------------------------
|
|
276
|
+
const unquote = (v) => { const t = v.trim(); return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1).replace(/\\"/g, '"') : t; };
|
|
277
|
+
const field = (block, name) => { const m = new RegExp(`(?:^|[\\s;{])${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*"|[^;\\s]+)\\s*;`).exec(block); return m ? unquote(m[1]) : null; };
|
|
278
|
+
|
|
279
|
+
/** { deps, problems } for the XCRemoteSwiftPackageReference objects of a project.pbxproj. */
|
|
280
|
+
export function parsePbxproj(source) {
|
|
281
|
+
const text = String(source); const deps = []; const problems = [];
|
|
282
|
+
const re = /isa\s*=\s*XCRemoteSwiftPackageReference\s*;/g; let m;
|
|
283
|
+
while ((m = re.exec(text))) {
|
|
284
|
+
const line = text.slice(0, m.index).split("\n").length;
|
|
285
|
+
const open = text.lastIndexOf("{", m.index);
|
|
286
|
+
let depth = 0; let end = -1;
|
|
287
|
+
for (let i = open; i < text.length; i++) { if (text[i] === "{") depth++; else if (text[i] === "}") { depth--; if (depth === 0) { end = i; break; } } }
|
|
288
|
+
if (open < 0 || end < 0) { problems.push({ line, text: "unbalanced XCRemoteSwiftPackageReference object" }); continue; }
|
|
289
|
+
const block = text.slice(open + 1, end);
|
|
290
|
+
const url = field(block, "repositoryURL");
|
|
291
|
+
const reqOpen = block.search(/requirement\s*=\s*\{/);
|
|
292
|
+
if (!url || reqOpen < 0) { problems.push({ line, text: "XCRemoteSwiftPackageReference has no repositoryURL or requirement the checker reads" }); continue; }
|
|
293
|
+
const req = block.slice(block.indexOf("{", reqOpen) + 1, block.indexOf("}", reqOpen));
|
|
294
|
+
const kind = field(req, "kind");
|
|
295
|
+
let requirement = null;
|
|
296
|
+
if (kind === "exactVersion") { const v = field(req, "version"); if (v && parseVersion(v)) requirement = { type: "exact", version: v }; }
|
|
297
|
+
else if (kind === "upToNextMajorVersion") { const v = field(req, "minimumVersion"); if (v && parseVersion(v)) requirement = { type: "upToNextMajor", version: v }; }
|
|
298
|
+
else if (kind === "upToNextMinorVersion") { const v = field(req, "minimumVersion"); if (v && parseVersion(v)) requirement = { type: "upToNextMinor", version: v }; }
|
|
299
|
+
else if (kind === "versionRange") { const a = field(req, "minimumVersion"); const b = field(req, "maximumVersion"); if (a && b && parseVersion(a) && parseVersion(b)) requirement = { type: "range", lower: a, upper: b, closed: false }; }
|
|
300
|
+
else if (kind === "branch") { const b = field(req, "branch"); if (b) requirement = { type: "branch", branch: b }; }
|
|
301
|
+
else if (kind === "revision") { const r = field(req, "revision"); if (r) requirement = { type: "revision", revision: r }; }
|
|
302
|
+
if (!requirement) { problems.push({ line, text: `requirement of ${url} cannot be read literally (kind ${kind ?? "missing"})` }); continue; }
|
|
303
|
+
deps.push({ line, kind: "remote", url, identity: identityOf(url), requirement });
|
|
304
|
+
}
|
|
305
|
+
return { deps, problems };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Findings for one `<name>.xcodeproj` directory (repo-relative). */
|
|
309
|
+
export function xcodeProjectLockProblems({ project, read, exists }) {
|
|
310
|
+
const pbx = `${project}/project.pbxproj`;
|
|
311
|
+
const resolvedFile = `${project}/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`;
|
|
312
|
+
const src = tryRead(read, exists, pbx);
|
|
313
|
+
if (!src) return [];
|
|
314
|
+
const { deps, problems } = parsePbxproj(src.text);
|
|
315
|
+
const out = problems.map((p) => `${pbx}:${p.line}: ${p.text}`);
|
|
316
|
+
const resolved = tryRead(read, exists, resolvedFile);
|
|
317
|
+
if (!resolved) {
|
|
318
|
+
if (deps.length) out.push(`${pbx}: ${deps.length} remote Swift ${deps.length === 1 ? "package" : "packages"} (${deps.map((d) => d.identity).join(", ")}) and no ${resolvedFile}; commit the lockfile`);
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
const parsed = parseResolved(resolved.text);
|
|
322
|
+
if (parsed.problems.length) return [...out, ...parsed.problems.map((p) => `${resolvedFile}: ${p}`)];
|
|
323
|
+
out.push(...matchPins(deps, parsed.pins, { label: (d) => `${pbx}:${d.line}`, resolvedFile }));
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The scan's repository-relative lock owners for a changed file: a Swift package directory or an `.xcodeproj` directory. */
|
|
328
|
+
export function lockOwnerOf(file, { exists, ancestorDirs }) {
|
|
329
|
+
const parts = file.split("/");
|
|
330
|
+
const xi = parts.findIndex((p) => p.endsWith(".xcodeproj"));
|
|
331
|
+
if (xi >= 0) return { kind: "xcode", dir: parts.slice(0, xi + 1).join("/") };
|
|
332
|
+
const owner = ancestorDirs(path.posix.dirname(file)).reverse().find((d) => exists(join(d, "Package.swift")));
|
|
333
|
+
return owner === undefined ? null : { kind: "package", dir: owner === "" ? "." : owner };
|
|
334
|
+
}
|
package/test-command.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import path from "node:path";
|
|
|
30
30
|
import { pathToFileURL } from "node:url";
|
|
31
31
|
import { spawn } from "./background-process.mjs";
|
|
32
32
|
import { carriedCargoMapOf, carriedLedgerIds, carriedOutputDirsSince } from "./carried-cargo.mjs";
|
|
33
|
+
import { GATE_TARGET_DIRS_ENV } from "./qa-layout.mjs";
|
|
33
34
|
import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
|
|
34
35
|
import { defaultReleaseConfigFile } from "./release-config.mjs";
|
|
35
36
|
import { fsListRustFiles } from "./signing-files.mjs";
|
|
@@ -167,6 +168,11 @@ export function planTests(config, { suites = [], runner, passthrough = [], appRo
|
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
/** The refusal code of a planned `rightkit-qa journeys run` suite (GB-79). */
|
|
171
|
+
/** RIGHTKIT_GATE_TARGET_DIRS: the absolute directories the gate pinned; none when unset or malformed. */
|
|
172
|
+
function directOutputDirs(env) {
|
|
173
|
+
try { const dirs = JSON.parse(env[GATE_TARGET_DIRS_ENV] ?? "[]"); return Array.isArray(dirs) ? dirs.filter((d) => typeof d === "string" && path.isAbsolute(d)) : []; } catch { return []; }
|
|
174
|
+
}
|
|
175
|
+
|
|
170
176
|
export const QA_JOURNEYS_UNSUPPORTED = "RK_QA_JOURNEYS_PREBUILT_UNSUPPORTED";
|
|
171
177
|
/** True when a suite step is `rightkit-qa journeys run ...` (by command or by node script name, extra args included). */
|
|
172
178
|
export function qaJourneysRunStep(step, extraArgs = []) {
|
|
@@ -187,8 +193,10 @@ export function eventPrefix(config) {
|
|
|
187
193
|
* Resolves `{ exitCode, results: [{ suite, exitCode, durationMs, skipped?, reason?, error? }] }`; a signal (cancellation) rejects.
|
|
188
194
|
* A `sign` step (suite-signing.mjs) runs IN THIS PROCESS, never through `door` (it is no command and never a broker job); it signs what an earlier
|
|
189
195
|
* suite built, named by that suite's verified carried receipts, and only a success sets RIGHTKIT_QA_APP_BINARY for the suites after it.
|
|
196
|
+
* On a direct host (no carrier) a suite's outputs are the Cargo output directories the gate pinned for the stage (RIGHTKIT_GATE_TARGET_DIRS).
|
|
197
|
+
* `within({ outputs, env })`, when given, runs once every suite passed and before the signed copies are removed; its result is returned as `within`.
|
|
190
198
|
*/
|
|
191
|
-
export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, errLog = console.error, door = buildLaunch, config = null, platform = process.platform, signDeps = {}, signStep = runSignStep, snapshotStage = Boolean(carriedCargoMapOf(env)?.map), qaCapability = (qa) => qaPrebuiltCapability(qa, { env }) } = {}) {
|
|
199
|
+
export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, errLog = console.error, door = buildLaunch, config = null, platform = process.platform, signDeps = {}, signStep = runSignStep, within = null, snapshotStage = Boolean(carriedCargoMapOf(env)?.map), qaCapability = (qa) => qaPrebuiltCapability(qa, { env }) } = {}) {
|
|
192
200
|
const results = [];
|
|
193
201
|
// One signing policy, enforced here whatever the config's format (the route probe reads JSON configs only; a legacy .mjs config is judged now): an
|
|
194
202
|
// unsigned or ad hoc product build, or an identifier override, is refused before any suite runs (rightkit#1303). Mac and Dell only.
|
|
@@ -294,14 +302,16 @@ export async function runSuites(plan, { appRoot, prefix = "test", env = process.
|
|
|
294
302
|
const durationMs = Date.now() - started;
|
|
295
303
|
// Only a suite that passed hands its receipts to a sign step.
|
|
296
304
|
if (exitCode === 0) {
|
|
297
|
-
const dirs = carriedOutputDirsSince(env, before);
|
|
305
|
+
const dirs = carriedCargoMapOf(env)?.map ? carriedOutputDirsSince(env, before) : directOutputDirs(env);
|
|
298
306
|
for (const key of new Set([item.name, item.group])) outputs.set(key, [...new Set([...(outputs.get(key) ?? []), ...dirs])]);
|
|
299
307
|
}
|
|
300
308
|
log(JSON.stringify({ event: `${prefix}_suite_completed`, suite: item.name, exitCode, durationMs }));
|
|
301
309
|
results.push({ suite: item.name, exitCode, durationMs, ...(failure ? { error: String(failure.message ?? failure) } : {}) });
|
|
302
310
|
record(item, exitCode === 0 ? "passed" : "failed");
|
|
303
311
|
}
|
|
304
|
-
|
|
312
|
+
const exitCode = results.some((r) => r.skipped !== true && r.exitCode !== 0) || results.some((r) => r.skipped) ? firstFailure() : 0;
|
|
313
|
+
if (exitCode !== 0 || !within) return { exitCode, results };
|
|
314
|
+
return { exitCode, results, within: await within({ outputs, env: suiteEnv }) };
|
|
305
315
|
} finally {
|
|
306
316
|
// The signed copies live only as long as the run that signed them.
|
|
307
317
|
for (const dir of signedDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* the managed root is pruned */ } }
|
package/tool-build.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The gate's tool route: `rightkit-gate --to tool --unit cargo:rightkit-qa [--no-broker <exception-id>]`.
|
|
2
|
+
//
|
|
3
|
+
// Release QA runs only an ENROLLED rightkit-qa runner of the exact version an app's `qa.version` names (qa-runner.mjs). The unit stages cannot
|
|
4
|
+
// make that binary: `--to compile|package --unit cargo:rightkit-qa` runs the unit's test plan and packs a .crate, and refuses a version that is
|
|
5
|
+
// already on crates.io (local-publish.mjs); `cargo install` is denied (cargo-shapes.mjs). This route is the one sanctioned way to produce it:
|
|
6
|
+
//
|
|
7
|
+
// 1. the unit's class A check (the compile tier), exactly as a unit stage runs it;
|
|
8
|
+
// 2. `cargo build --release --locked --bin <bin>` in the unit's own Cargo workspace of the selected commit's snapshot, through the same executor,
|
|
9
|
+
// cargo wrapper or carrier and target-directory binding as every other unit compile (unit-gate.mjs / bindUnitCargo). There is NO registry-version
|
|
10
|
+
// check: a tool build of a published version is the point of it, and it publishes nothing;
|
|
11
|
+
// 3. the produced binary is enrolled (qa-runner.mjs `enrollQaRunner`): version = the crate's COMMITTED version, answered by the binary's own
|
|
12
|
+
// `--version`, a COPY recorded with its sha256 under the managed data root. The enrolled path and sha256 are printed so the owner can add the
|
|
13
|
+
// admission-policy entry (`right-release qa policy approve-runner`). Admission itself is unchanged and stays the owner's.
|
|
14
|
+
//
|
|
15
|
+
// `cargo:rightkit-credentials` takes the same route and is enrolled as the managed OS credential provider (credential-provider.mjs) that updater signing runs.
|
|
16
|
+
// Only units named in TOOL_UNITS have this route. The route never uploads, signs or publishes, and it enrols only on a Mac or Windows host (the platforms
|
|
17
|
+
// a runner is enrolled for: its bytes are executed here to read their version).
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { endRun, startRun } from "./gate-stages.mjs";
|
|
22
|
+
import { runCargoSync } from "./managed-cargo.mjs";
|
|
23
|
+
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
24
|
+
import { CredentialProviderError, enrollCredentialProvider } from "./credential-provider.mjs";
|
|
25
|
+
import { QaRunnerError, enrollQaRunner } from "./qa-runner.mjs";
|
|
26
|
+
import { knownRedEntry, resolveUnit } from "./unit-gate.mjs";
|
|
27
|
+
|
|
28
|
+
export const TOOL_STAGE = "tool";
|
|
29
|
+
export const TOOL_REPORT_SCHEMA = "rightkit.tool-build-report/1";
|
|
30
|
+
/** The units that have a tool route: the crate, the binary its manifest declares, and what the binary is enrolled as. */
|
|
31
|
+
export const TOOL_UNITS = Object.freeze({
|
|
32
|
+
"cargo:rightkit-qa": Object.freeze({ crate: "rightkit-qa", bin: "rightkit-qa", enrols: "qa-runner" }),
|
|
33
|
+
"cargo:rightkit-credentials": Object.freeze({ crate: "rightkit-credentials", bin: "rightkit-credentials", enrols: "credential-provider" }),
|
|
34
|
+
});
|
|
35
|
+
const HOST_PLATFORM = Object.freeze({ darwin: "mac", win32: "win" });
|
|
36
|
+
const BUILD_TIMEOUT_MS = 30 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
export class ToolBuildError extends Error {
|
|
39
|
+
constructor(code, message) { super(`${code}: ${message}`); this.name = "ToolBuildError"; this.code = code; }
|
|
40
|
+
}
|
|
41
|
+
const refuse = (code, message) => { throw new ToolBuildError(code, message); };
|
|
42
|
+
|
|
43
|
+
/** The cargo arguments of the build (no `cargo`): the one shape the broker admits as a compile and a stage grant carries (cargo-shapes.mjs). */
|
|
44
|
+
export const toolBuildArgs = (bin) => ["build", "--release", "--locked", "--bin", bin];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What `--to tool --unit <selector>` builds on `platform` (a `process.platform` value), decided before ownership, a snapshot or any process:
|
|
48
|
+
* `{ selector, crate, bin, enrols, cargoDir, argv, executable, runnerPlatform }`, or a typed ToolBuildError.
|
|
49
|
+
*/
|
|
50
|
+
export function toolPlan({ selector, platform }) {
|
|
51
|
+
const unit = Object.hasOwn(TOOL_UNITS, selector ?? "") ? TOOL_UNITS[selector] : null;
|
|
52
|
+
if (!unit) refuse("RK_TOOL_UNIT_UNSUPPORTED", `${JSON.stringify(selector ?? null)} has no tool route (${Object.keys(TOOL_UNITS).join(", ")}); \`--to tool\` builds only a unit named there`);
|
|
53
|
+
const runnerPlatform = HOST_PLATFORM[platform];
|
|
54
|
+
if (!runnerPlatform) refuse("RK_TOOL_HOST", `a ${unit.bin} runner is built and enrolled on a Mac or a Windows host (this is ${platform}): its bytes are executed there to read their version, and a runner is enrolled per platform`);
|
|
55
|
+
return { selector, ...unit, cargoDir: `crates/${unit.crate}`, argv: toolBuildArgs(unit.bin), executable: runnerPlatform === "win" ? `${unit.bin}.exe` : unit.bin, runnerPlatform };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The pinned target directory of the Cargo workspace that holds `cargoDir`: its own entry, else the nearest enclosing one (a crate that is a member of a parent workspace). Null when none is pinned. */
|
|
59
|
+
export function pinnedTargetDir(targetDirs, cargoDir) {
|
|
60
|
+
for (let dir = cargoDir; ; dir = path.posix.dirname(dir)) {
|
|
61
|
+
if (typeof targetDirs?.[dir] === "string" && targetDirs[dir]) return targetDirs[dir];
|
|
62
|
+
if (dir === "." || dir === "/" || dir === "") return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sha256OfFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The built binary under `outputDir` (the target directory: the carried receipt's `outputDir` on a broker host, the checkout's pinned target on a direct
|
|
70
|
+
* one): `<outputDir>/release/<executable>`, a regular file the build just wrote, never a link. Returns its path or throws.
|
|
71
|
+
*/
|
|
72
|
+
export function builtToolBinary({ outputDir, executable }) {
|
|
73
|
+
if (typeof outputDir !== "string" || !path.isAbsolute(outputDir)) refuse("RK_TOOL_NO_OUTPUT_DIR", `the build reported no absolute output directory (${JSON.stringify(outputDir ?? null)}); the produced binary cannot be located, so nothing was enrolled`);
|
|
74
|
+
const file = path.join(outputDir, "release", executable);
|
|
75
|
+
let meta;
|
|
76
|
+
try { meta = fs.lstatSync(file); } catch (error) { refuse("RK_TOOL_NO_BINARY", `the build succeeded but ${file} does not exist (${error.code ?? error.message}); nothing was enrolled`); }
|
|
77
|
+
if (meta.isSymbolicLink() || !meta.isFile()) refuse("RK_TOOL_NO_BINARY", `${file} is not a regular file; nothing was enrolled`);
|
|
78
|
+
return file;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Runs the tool route for one unit of the selected commit's snapshot. `root` is the snapshot, `commonDir` the checkout's git directory. `check(tier)`
|
|
83
|
+
* is the door's class A run and `stageGuard` its ledger verify around each stage (as runUnitGate takes them); `envExtra` is the Cargo binding
|
|
84
|
+
* (bindUnitCargo's env) and `targetDirs` its `{ [cargoDir]: checkout target directory }` for a direct host. Everything a test replaces is a parameter:
|
|
85
|
+
* `run` (the cargo call: managed-cargo's runCargoSync, which carries it through the broker or runs it direct), `enroll`, `resolve`, `dataRoot`.
|
|
86
|
+
* Returns the exit code (0, or 1 with the reason printed).
|
|
87
|
+
*/
|
|
88
|
+
export async function runUnitTool({
|
|
89
|
+
selector, root, commonDir, commit, tree, executor, env, platform, check, source = null, stageGuard = null, envExtra = {}, targetDirs = {},
|
|
90
|
+
resolve = resolveUnit, run = runCargoSync, enroll = enrollQaRunner, enrollProvider = enrollCredentialProvider, dataRoot = undefined, readKnownRed = null,
|
|
91
|
+
out = console.log, err = console.error, writeReport = null,
|
|
92
|
+
}) {
|
|
93
|
+
let plan; let unit;
|
|
94
|
+
try {
|
|
95
|
+
plan = toolPlan({ selector, platform });
|
|
96
|
+
unit = resolve(selector, root);
|
|
97
|
+
const red = knownRedEntry({ unit, root, platform, ...(readKnownRed ? { readText: readKnownRed } : {}) });
|
|
98
|
+
if (red) refuse("RK_TOOL_KNOWN_RED", `${selector} is listed in ci/known-red.json for ${red.host} (${red.key}: ${red.reason}); a known-red unit is not built. Fix the defect and remove the entry first`);
|
|
99
|
+
} catch (error) { err(`rightkit-gate: BLOCKED: ${error.message}`); return 1; }
|
|
100
|
+
const report = { schema: TOOL_REPORT_SCHEMA, unit, commit, tree, executor: executor?.kind ?? null, stages: {} };
|
|
101
|
+
const save = () => { if (writeReport) writeReport(report); };
|
|
102
|
+
let stage = "check";
|
|
103
|
+
try {
|
|
104
|
+
if (stageGuard && !(await stageGuard.start("check"))) throw new Error("RK_CHECK_ERROR: the snapshot is not the commit's bytes plus the ledger before the check stage");
|
|
105
|
+
if (await check("compile") !== 0) throw new Error("class A check (compile tier) failed");
|
|
106
|
+
if (stageGuard && !(await stageGuard.end("check"))) throw new Error("RK_CHECK_ERROR: the check stage's result covers no known bytes");
|
|
107
|
+
report.stages.check = { ok: true }; save();
|
|
108
|
+
out(`rightkit-gate: [check] ${selector} passed`);
|
|
109
|
+
|
|
110
|
+
stage = TOOL_STAGE;
|
|
111
|
+
if (stageGuard && !(await stageGuard.start(TOOL_STAGE))) throw new Error(`RK_CHECK_ERROR: the snapshot is not the commit's bytes plus the ledger before the ${TOOL_STAGE} stage`);
|
|
112
|
+
const doorRun = startRun({ commonDir, commit, tree, stage: TOOL_STAGE });
|
|
113
|
+
let built;
|
|
114
|
+
try {
|
|
115
|
+
const childEnv = { ...env, ...envExtra, ...doorRun.env };
|
|
116
|
+
const cwd = path.join(root, ...plan.cargoDir.split("/"));
|
|
117
|
+
out(`rightkit-gate: [tool] ${selector}@${unit.version}: cargo ${plan.argv.join(" ")} (from ${plan.cargoDir}; executor ${executor?.kind ?? "?"})`);
|
|
118
|
+
const result = await run(plan.argv, { cwd, env: childEnv, encoding: "utf8", maxBuffer: 512 * 1024 * 1024, timeout: BUILD_TIMEOUT_MS, windowsHide: true });
|
|
119
|
+
if (result?.error || result?.status !== 0) {
|
|
120
|
+
throw new Error(`${selector}: cargo ${plan.argv.join(" ")} failed (${result?.error?.message ?? `exit ${result?.status}`})\nstdout:\n${String(result?.stdout ?? "").slice(-4000)}\nstderr:\n${String(result?.stderr ?? "").slice(-4000)}`);
|
|
121
|
+
}
|
|
122
|
+
// A carried (broker) call names the receipt's output directory; a direct call wrote the checkout target the gate pinned for this workspace.
|
|
123
|
+
built = builtToolBinary({ outputDir: result.outputDir ?? pinnedTargetDir(targetDirs, plan.cargoDir), executable: plan.executable });
|
|
124
|
+
} finally { endRun(doorRun); }
|
|
125
|
+
if (stageGuard && !(await stageGuard.end(TOOL_STAGE))) throw new Error(`RK_CHECK_ERROR: the ${TOOL_STAGE} stage's result covers no known bytes (a snapshot byte changed, a write is outside the ledger, or a cargo call did not complete)`);
|
|
126
|
+
const builtSha256 = sha256OfFile(built);
|
|
127
|
+
report.stages.tool = { ok: true, binary: built, binarySha256: builtSha256, version: unit.version }; save();
|
|
128
|
+
out(`rightkit-gate: [tool] built ${built} (sha256 ${builtSha256})`);
|
|
129
|
+
|
|
130
|
+
stage = "enrol";
|
|
131
|
+
let enrolled;
|
|
132
|
+
try {
|
|
133
|
+
const root_ = dataRoot ?? resolveManagedRoots({ env }).dataRoot;
|
|
134
|
+
enrolled = plan.enrols === "credential-provider"
|
|
135
|
+
? enrollProvider({ dataRoot: root_, platform: plan.runnerPlatform, binary: built, version: unit.version })
|
|
136
|
+
: enroll({ dataRoot: root_, platform: plan.runnerPlatform, runner: built, version: unit.version });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof CredentialProviderError || error?.code?.startsWith?.("RK_CRED_PROVIDER_")) throw new Error(`${error.code}: ${error.message}\nthe built binary is ${built} (sha256 ${builtSha256}); a deliberate replacement of an enrolled version is \`node packages/release/credential-provider.mjs enroll --binary ${built} --version ${unit.version} --platform ${plan.runnerPlatform} --rotate\``);
|
|
139
|
+
if (error instanceof QaRunnerError || error?.code?.startsWith?.("RK_QA_RUNNER_")) throw new Error(`${error.code}: ${error.message}\nthe built binary is ${built} (sha256 ${builtSha256}); a deliberate replacement of an enrolled version is \`right-release qa runner enroll --runner ${built} --version ${unit.version} --platform ${plan.runnerPlatform} --rotate\``);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
// The copy that was recorded must be the very bytes that were built: a binary swapped between the digest and the copy is refused.
|
|
143
|
+
if (enrolled.entry.sha256 !== builtSha256) throw new Error(`RK_TOOL_DIGEST_DRIFT: the enrolled copy ${enrolled.runnerFile} has sha256 ${enrolled.entry.sha256}, the build produced ${builtSha256}; the binary changed while it was enrolled, so it is not trusted (enrol it again from a fresh build)`);
|
|
144
|
+
report.stages.enrol = { ok: true, runner: enrolled.runnerFile, sha256: enrolled.entry.sha256, version: enrolled.entry.version, platform: plan.runnerPlatform, record: enrolled.file, rotated: enrolled.rotated };
|
|
145
|
+
save();
|
|
146
|
+
out(`rightkit-gate: [tool] enrolled ${plan.bin} ${enrolled.entry.version} for ${plan.runnerPlatform}: ${enrolled.runnerFile} sha256 ${enrolled.entry.sha256} (record ${enrolled.file})`);
|
|
147
|
+
if (plan.enrols === "credential-provider") out("rightkit-gate: [tool] updater signing and the admission check resolve this enrolled provider (credential-provider.mjs); PATH is not used on a Mac or Windows host");
|
|
148
|
+
else out(`rightkit-gate: [tool] the owner admits it with: right-release qa policy approve-runner --runner ${enrolled.runnerFile} --version ${enrolled.entry.version} --platform ${plan.runnerPlatform} --set-by <name>`);
|
|
149
|
+
out(`rightkit-gate: [tool] ${selector} passed`);
|
|
150
|
+
return 0;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
report.stages[stage] = { ok: false }; save();
|
|
153
|
+
err(`rightkit-gate: tool ${selector} stopped at ${stage}: ${error.message}`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
package/unit-gate.mjs
CHANGED
|
@@ -69,7 +69,12 @@ export function writeQualification({ report, artifact, unit, commit, tree, runti
|
|
|
69
69
|
if (!/^[0-9a-f]{64}$/.test(artifact.contentSha256 ?? "")) throw new Error("release needs the qualified npm archive's content digest");
|
|
70
70
|
content.contentSha256 = artifact.contentSha256;
|
|
71
71
|
const excluded = exclusionsFor(unit.name);
|
|
72
|
-
if (excluded.length) {
|
|
72
|
+
if (excluded.length && executor?.kind === "direct") {
|
|
73
|
+
// A Linux direct origin neither builds nor signs the swap helper (publish.yml's build-swap/sign-swap do, hosted): its archive holds neither
|
|
74
|
+
// entry, and says so. The Mac rule below is unchanged.
|
|
75
|
+
if (!Array.isArray(artifact.contentExcluded) || artifact.contentExcluded.length) throw new Error(`a direct origin's ${unit.name} archive must hold neither ${excluded.join(" nor ")}`);
|
|
76
|
+
content.contentExcluded = [];
|
|
77
|
+
} else if (excluded.length) {
|
|
73
78
|
if (!Array.isArray(artifact.contentExcluded) || [...artifact.contentExcluded].sort().join("\n") !== [...excluded].sort().join("\n")) throw new Error(`release needs the qualified ${unit.name} archive to hold ${excluded.join(" and ")}`);
|
|
74
79
|
content.contentExcluded = [...artifact.contentExcluded].sort();
|
|
75
80
|
}
|
|
@@ -348,6 +353,8 @@ export async function runUnitGate({ selector, to, root, commonDir, commit, tree,
|
|
|
348
353
|
}
|
|
349
354
|
// GB-29: a Linux origin has no managed volume; its scratch is the gate's own state directory (XDG state), never a Mac path.
|
|
350
355
|
const scratch = platform === "win32" ? "D:\\scratch" : platform === "linux" ? path.join(env.XDG_STATE_HOME || path.join(env.HOME || "/tmp", ".local", "state"), "rightkit", "scratch") : "/Volumes/D/scratch";
|
|
356
|
+
// The Linux scratch is a gate-owned state directory that may not exist yet; the bundle itself is still created fresh (non-recursive) by the publisher.
|
|
357
|
+
if (platform === "linux") fs.mkdirSync(scratch, { recursive: true, mode: 0o700 });
|
|
351
358
|
const bundle = path.join(scratch, `rk-unit-${randomBytes(12).toString("hex")}`);
|
|
352
359
|
const argv = ["--local", unit.kind === "npm" ? "--package" : "--crate", unit.name, "--workspace", root, "--qualify-only", "--bundle", bundle];
|
|
353
360
|
const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -428,7 +435,7 @@ export async function runUnitGate({ selector, to, root, commonDir, commit, tree,
|
|
|
428
435
|
// GB-29: the direct-origin qualification is written and verified exactly as the hosted side will. Dispatching it needs the publish workflow's `direct` admission input, which is the
|
|
429
436
|
// reserved publication authorization (GC-T22): nothing is dispatched from a direct host until that input exists.
|
|
430
437
|
const written = directQualification();
|
|
431
|
-
throw new Error(`RK_DIRECT_RELEASE_HELD: the direct-origin qualification ${written} was written and verified;
|
|
438
|
+
throw new Error(`RK_DIRECT_RELEASE_HELD: the direct-origin qualification ${written} was written and verified; a direct host has no broker dispatch adapter, so dispatch publish.yml from main with admission=direct and this file as the qualification input (rightkit#1341); nothing was dispatched from here`);
|
|
432
439
|
}
|
|
433
440
|
requireBrokerHost(executor); // a direct host has no broker receipts to carry: refuse for that reason, not for the missing receipts
|
|
434
441
|
const qualification = writeQualification({ report, artifact, unit, commit, tree, runtime, executor });
|