@rightkit/release 0.6.16 → 0.6.20
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 +469 -102
- 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 +247 -40
- 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 +107 -9
- package/snapshot-check.mjs +2 -0
- 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.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/gate-stages.mjs
CHANGED
|
@@ -54,6 +54,8 @@ export const STAGE_SOURCES = Object.freeze({
|
|
|
54
54
|
"unit:compile": { compiles: true, packages: false, source: "snapshot" },
|
|
55
55
|
"unit:package": { compiles: true, packages: true, source: "snapshot" },
|
|
56
56
|
"unit:qualify": { compiles: false, packages: false, source: "snapshot" },
|
|
57
|
+
// `--to tool --unit <selector>` (tool-build.mjs): builds one named tool binary from the commit's snapshot and enrols it; it packages and uploads nothing.
|
|
58
|
+
"unit:tool": { compiles: true, packages: false, source: "snapshot" },
|
|
57
59
|
"unit:release": { compiles: false, packages: false, source: "publisher", basis: "a unit release is dispatched to the trusted publisher (releaseUnit); it compiles nothing here" },
|
|
58
60
|
});
|
|
59
61
|
|
|
@@ -228,10 +230,39 @@ export function compileKeyOf(env = {}) {
|
|
|
228
230
|
return sha256(JSON.stringify(picked));
|
|
229
231
|
}
|
|
230
232
|
|
|
231
|
-
/**
|
|
232
|
-
export
|
|
233
|
+
/** The profile directory a gate compile writes (the dev profile; the install route reads its binaries there). */
|
|
234
|
+
export const COMPILE_PROFILE_DIR = "debug";
|
|
235
|
+
|
|
236
|
+
/** How `binaries` was chosen: only the bin targets the compile built in that directory (a consumer requires this marker). */
|
|
237
|
+
export const BUILT_BINARIES_SCHEMA = "built-bin-targets/1";
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The sha256 of each binary this compile built (rustright#209 item 5): for every output directory, the files `<dir>/debug/<bin>[.exe]` of the
|
|
241
|
+
* bin targets the compile built there (`builtBins`, { [dir]: [bin target name] }: the units whose scans ran `cargo build -p <crate>` in that
|
|
242
|
+
* directory's workspace, so each binary was rebuilt or proven fresh by Cargo for this commit). Any other file in `debug/`, a stale binary of a
|
|
243
|
+
* removed or renamed target or of a package this run did not build, is never recorded. A census proves nothing once truncated (a broker
|
|
244
|
+
* workspace target is tens of GB, past OUTPUT_CENSUS_LIMIT); a consumer that installs a binary needs only that binary's bytes to be the compile's.
|
|
245
|
+
* A target whose file is missing is recorded with `sha256: null`. Sorted by directory, then name.
|
|
246
|
+
*/
|
|
247
|
+
export function compiledBinaries(builtBins = {}, { platform = process.platform, stat = (f) => fs.statSync(f), readFile = (f) => fs.readFileSync(f) } = {}) {
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const dir of Object.keys(builtBins ?? {}).filter((d) => typeof d === "string" && d).sort()) {
|
|
250
|
+
for (const bin of [...new Set(builtBins[dir] ?? [])].sort()) {
|
|
251
|
+
const name = `${bin}${platform === "win32" ? ".exe" : ""}`;
|
|
252
|
+
const file = path.join(dir, COMPILE_PROFILE_DIR, name);
|
|
253
|
+
let info = null;
|
|
254
|
+
try { info = stat(file); } catch { info = null; }
|
|
255
|
+
out.push(info?.isFile?.() ? { dir, name, size: info.size, sha256: sha256(readFile(file)) } : { dir, name, size: null, sha256: null });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** What a passed compile stage records about its outputs: the directories, their census, the built binaries' digests and the key of the compile environment. */
|
|
262
|
+
export function compileOutputsRecord({ dirs = [], env = {}, builtBins = null, census = outputCensus, binaries = compiledBinaries }) {
|
|
233
263
|
const c = census(dirs);
|
|
234
|
-
|
|
264
|
+
const known = builtBins && typeof builtBins === "object" ? Object.fromEntries(Object.entries(builtBins).filter(([dir]) => c.dirs.includes(dir))) : null;
|
|
265
|
+
return { dirs: c.dirs, entries: c.entries, digest: c.digest, truncated: c.truncated, ...(known ? { binaries: binaries(known), binariesOf: BUILT_BINARIES_SCHEMA } : {}), key: compileKeyOf(env) };
|
|
235
266
|
}
|
|
236
267
|
|
|
237
268
|
/**
|
package/gradle-lock.mjs
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// File-only Gradle dependency-locking check (class A scan `gradle-lock`). Gradle resolves dependencies at build time, so a build with no
|
|
2
|
+
// committed lock can drift between hosts; with a lock, a stale one fails here instead of inside the compile. This module reads committed
|
|
3
|
+
// files and executes nothing (no Gradle, no wrapper). It checks DIRECT dependencies only: each project directory that declares dependencies
|
|
4
|
+
// must have its own gradle.lockfile (or legacy gradle/dependency-locks/*.lockfile), and every literal direct dependency (build.gradle[.kts]
|
|
5
|
+
// strings and the libs.versions.toml entries the build references) must have a locked version at or above the declared one. Dynamic or
|
|
6
|
+
// interpolated versions are checked for presence only; a declaration that cannot be read literally is a finding, never a silent pass.
|
|
7
|
+
const NOT_SET_UP = "Gradle dependency locking is not set up: run ./gradlew dependencies --write-locks and commit gradle.lockfile";
|
|
8
|
+
const join = (dir, f) => (dir === "" || dir === "." ? f : `${dir}/${f}`);
|
|
9
|
+
const SKIP_DIRS = new Set([".git", ".gradle", "build", "node_modules", ".idea", "out", "target", ".build"]);
|
|
10
|
+
const BUILD_FILE = /^build\.gradle(\.kts)?$/;
|
|
11
|
+
export const GRADLE_BUILD_FILE = /(^|\/)(build|settings)\.gradle(\.kts)?$/;
|
|
12
|
+
|
|
13
|
+
// ---- versions -------------------------------------------------------------------------------------------------------------
|
|
14
|
+
const tokensOf = (v) => String(v).split(/[.\-_+]/).filter((t) => t !== "");
|
|
15
|
+
const RELEASE_WORDS = new Set(["final", "ga", "release", "sp"]);
|
|
16
|
+
/** Gradle-like version order (approximate): numeric segments by value, qualifiers below the plain release. */
|
|
17
|
+
export function compareGradleVersions(a, b) {
|
|
18
|
+
const x = tokensOf(a); const y = tokensOf(b);
|
|
19
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
20
|
+
const p = x[i]; const q = y[i];
|
|
21
|
+
if (p === undefined) return /^\d+$/.test(q) ? (Number(q) === 0 ? 0 : -1) : RELEASE_WORDS.has(q.toLowerCase()) ? 0 : 1;
|
|
22
|
+
if (q === undefined) return /^\d+$/.test(p) ? (Number(p) === 0 ? 0 : 1) : RELEASE_WORDS.has(p.toLowerCase()) ? 0 : -1;
|
|
23
|
+
const pn = /^\d+$/.test(p); const qn = /^\d+$/.test(q);
|
|
24
|
+
if (pn && qn) { if (Number(p) !== Number(q)) return Number(p) < Number(q) ? -1 : 1; continue; }
|
|
25
|
+
if (pn !== qn) return pn ? 1 : -1;
|
|
26
|
+
if (p.toLowerCase() !== q.toLowerCase()) return p.toLowerCase() < q.toLowerCase() ? -1 : 1;
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
const isDynamic = (v) => v === null || v === undefined || /[+[\](),]|^latest\.|\$/.test(v);
|
|
31
|
+
|
|
32
|
+
// ---- lockfiles ------------------------------------------------------------------------------------------------------------
|
|
33
|
+
/** Map "group:artifact" -> Set of locked versions, from gradle.lockfile text (`g:a:v=config,config`; comments and `empty=` ignored). */
|
|
34
|
+
export function parseLockfile(text, into = new Map()) {
|
|
35
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
36
|
+
const line = raw.trim();
|
|
37
|
+
if (!line || line.startsWith("#") || line.startsWith("empty=")) continue;
|
|
38
|
+
const m = /^([^:=\s]+):([^:=\s]+):([^=\s]+)=/.exec(line);
|
|
39
|
+
if (!m) continue;
|
|
40
|
+
const key = `${m[1]}:${m[2]}`;
|
|
41
|
+
if (!into.has(key)) into.set(key, new Set());
|
|
42
|
+
into.get(key).add(m[3]);
|
|
43
|
+
}
|
|
44
|
+
return into;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- libs.versions.toml (the literal shapes Gradle documents; no TOML library needed) -------------------------------------
|
|
48
|
+
function stripTomlComment(line) {
|
|
49
|
+
let inStr = false;
|
|
50
|
+
for (let i = 0; i < line.length; i++) {
|
|
51
|
+
const c = line[i];
|
|
52
|
+
if (c === '"' && line[i - 1] !== "\\") inStr = !inStr;
|
|
53
|
+
else if (c === "#" && !inStr) return line.slice(0, i);
|
|
54
|
+
}
|
|
55
|
+
return line;
|
|
56
|
+
}
|
|
57
|
+
const tomlStr = (s) => { const m = /^"((?:[^"\\]|\\.)*)"$/.exec(String(s).trim()); return m ? m[1] : null; };
|
|
58
|
+
function inlineFields(body) {
|
|
59
|
+
// `key = "v"`, `key.ref = "v"`, `key = { ref = "v" }` inside one inline table (one level of nesting).
|
|
60
|
+
const out = {}; const re = /([A-Za-z_][\w.-]*)\s*=\s*("(?:[^"\\]|\\.)*"|\{[^}]*\})/g; let m;
|
|
61
|
+
while ((m = re.exec(body))) {
|
|
62
|
+
if (m[2].startsWith("{")) { for (const [k, v] of Object.entries(inlineFields(m[2].slice(1, -1)))) out[`${m[1]}.${k}`] = v; } else out[m[1]] = tomlStr(m[2]);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** { versions, libraries: Map(alias -> { module, version, line }), bundles: Map(name -> [alias]), problems: [{ line, text }] } */
|
|
67
|
+
export function parseCatalog(text) {
|
|
68
|
+
const versions = {}; const libraries = new Map(); const bundles = new Map(); const problems = []; const refs = [];
|
|
69
|
+
let section = null;
|
|
70
|
+
String(text).split(/\r?\n/).forEach((raw, i) => {
|
|
71
|
+
const line = stripTomlComment(raw).trim();
|
|
72
|
+
if (!line) return;
|
|
73
|
+
const sec = /^\[([\w.-]+)\]$/.exec(line);
|
|
74
|
+
if (sec) { section = sec[1]; return; }
|
|
75
|
+
const kv = /^([A-Za-z_][\w.-]*)\s*=\s*(.*)$/.exec(line);
|
|
76
|
+
if (!kv) return;
|
|
77
|
+
const [, key, value] = kv; const n = i + 1;
|
|
78
|
+
if (section === "versions") {
|
|
79
|
+
const s = tomlStr(value);
|
|
80
|
+
if (s !== null) versions[key] = s;
|
|
81
|
+
else if (value.startsWith("{")) { const f = inlineFields(value.slice(1, value.lastIndexOf("}"))); versions[key] = f.strictly ?? f.require ?? f.prefer ?? null; if (versions[key] === null) problems.push({ line: n, text: `version ${key} has no strictly/require/prefer literal` }); }
|
|
82
|
+
else problems.push({ line: n, text: `version ${key} is not a literal the checker reads` });
|
|
83
|
+
} else if (section === "libraries") {
|
|
84
|
+
const s = tomlStr(value);
|
|
85
|
+
if (s !== null) {
|
|
86
|
+
const p = s.split(":");
|
|
87
|
+
if (p.length >= 2 && p.length <= 3) libraries.set(key, { module: `${p[0]}:${p[1]}`, version: p[2] ?? null, line: n });
|
|
88
|
+
else problems.push({ line: n, text: `library ${key} is not group:artifact[:version]` });
|
|
89
|
+
} else if (value.startsWith("{")) {
|
|
90
|
+
const f = inlineFields(value.slice(1, value.lastIndexOf("}")));
|
|
91
|
+
const module = f.module ?? (f.group && f.name ? `${f.group}:${f.name}` : null);
|
|
92
|
+
if (!module) { problems.push({ line: n, text: `library ${key} has no module or group/name` }); return; }
|
|
93
|
+
const ref = f["version.ref"] ?? f["version.ref"] ?? null;
|
|
94
|
+
const direct = f.version ?? f["version.strictly"] ?? f["version.require"] ?? f["version.prefer"] ?? null;
|
|
95
|
+
const lib = { module, version: direct, line: n };
|
|
96
|
+
if (ref !== null) refs.push([lib, ref]);
|
|
97
|
+
libraries.set(key, lib);
|
|
98
|
+
} else problems.push({ line: n, text: `library ${key} is not a literal the checker reads` });
|
|
99
|
+
} else if (section === "bundles") {
|
|
100
|
+
const members = [...value.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
101
|
+
bundles.set(key, members);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
for (const [lib, ref] of refs) {
|
|
105
|
+
if (Object.hasOwn(versions, ref)) lib.version = versions[ref];
|
|
106
|
+
else problems.push({ line: lib.line, text: `library ${lib.module} references version ${ref}, which [versions] does not define` });
|
|
107
|
+
}
|
|
108
|
+
return { versions, libraries, bundles, problems };
|
|
109
|
+
}
|
|
110
|
+
const aliasKey = (a) => a.replace(/[-_.]/g, ".").toLowerCase();
|
|
111
|
+
|
|
112
|
+
// ---- build files ----------------------------------------------------------------------------------------------------------
|
|
113
|
+
const CONFIG = "(?:api|implementation|compileOnly|runtimeOnly|compile|runtime|annotationProcessor|kapt|ksp|coreLibraryDesugaring|lintChecks|lintPublish|[a-z][A-Za-z]*(?:Implementation|Api|CompileOnly|RuntimeOnly|AnnotationProcessor|Kapt|Ksp|Compile|Runtime))";
|
|
114
|
+
const DEP_LINE = new RegExp(`^\\s*(${CONFIG})\\b\\s*\\(?\\s*(?:platform\\(\\s*|enforcedPlatform\\(\\s*)?(.*)$`);
|
|
115
|
+
function stripGradleComments(text) {
|
|
116
|
+
return String(text).replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).split("\n").map((l) => l.replace(/(^|[^:"'\w])\/\/.*$/, "$1")).join("\n");
|
|
117
|
+
}
|
|
118
|
+
/** [{ line, config, kind: "gav"|"alias"|"bundle"|"unreadable", group, name, version, alias, text }] from a build.gradle[.kts]'s dependency lines. */
|
|
119
|
+
export function parseBuildDependencies(source) {
|
|
120
|
+
const out = [];
|
|
121
|
+
stripGradleComments(source).split("\n").forEach((l, i) => {
|
|
122
|
+
const m = DEP_LINE.exec(l);
|
|
123
|
+
if (!m) return;
|
|
124
|
+
const line = i + 1; const config = m[1]; let rest = m[2].trim();
|
|
125
|
+
if (/^(project|files|fileTree|gradleApi|localGroovy|gradleKotlinDsl)\b/.test(rest) || rest.startsWith("{") || rest === "") return;
|
|
126
|
+
let r;
|
|
127
|
+
if ((r = /^["']([^"':\s]+):([^"':\s]+)(?::([^"':\s]*))?["']/.exec(rest))) { out.push({ line, config, kind: "gav", group: r[1], name: r[2], version: r[3] ?? null }); return; }
|
|
128
|
+
if ((r = /^group\s*[:=]\s*["']([^"']+)["']\s*,\s*name\s*[:=]\s*["']([^"']+)["'](?:\s*,\s*version\s*[:=]\s*["']([^"']+)["'])?/.exec(rest))) { out.push({ line, config, kind: "gav", group: r[1], name: r[2], version: r[3] ?? null }); return; }
|
|
129
|
+
if ((r = /^libs\.bundles\.([\w.]+?)(?:\.get\(\))?\s*\)*\s*$/.exec(rest))) { out.push({ line, config, kind: "bundle", alias: r[1] }); return; }
|
|
130
|
+
if ((r = /^libs\.((?!plugins\.|versions\.)[\w.]+?)(?:\.get\(\))?\s*\)*\s*$/.exec(rest))) { out.push({ line, config, kind: "alias", alias: r[1] }); return; }
|
|
131
|
+
if ((r = /^kotlin\(\s*["']([\w.-]+)["']/.exec(rest))) { out.push({ line, config, kind: "gav", group: "org.jetbrains.kotlin", name: `kotlin-${r[1]}`, version: null }); return; }
|
|
132
|
+
out.push({ line, config, kind: "unreadable", text: rest.slice(0, 70) });
|
|
133
|
+
});
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** null when `locked` (Set) holds the dependency at or above `version`; else why not. Dynamic versions need only presence. */
|
|
138
|
+
function lockedProblem(locked, version) {
|
|
139
|
+
if (!locked || locked.size === 0) return "has no locked version";
|
|
140
|
+
if (isDynamic(version)) return null;
|
|
141
|
+
return [...locked].some((v) => compareGradleVersions(v, version) >= 0) ? null : `is locked at ${[...locked].join(", ")}, below the declared ${version}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Findings for the Gradle build rooted at `dir` ("." for the repository root). `read(file)` / `exists(file)` take repo-relative paths;
|
|
146
|
+
* `list(dir) -> [{ name, isDir }]` lists a directory.
|
|
147
|
+
*/
|
|
148
|
+
export function gradleLockProblems({ dir, read, exists, list }) {
|
|
149
|
+
const out = [];
|
|
150
|
+
const catalogFile = join(dir, "gradle/libs.versions.toml");
|
|
151
|
+
let catalog = null;
|
|
152
|
+
if (exists(catalogFile)) { catalog = parseCatalog(read(catalogFile)); out.push(...catalog.problems.map((p) => `${catalogFile}:${p.line}: ${p.text}`)); }
|
|
153
|
+
const builds = [];
|
|
154
|
+
const walk = (d, depth) => {
|
|
155
|
+
let entries = [];
|
|
156
|
+
try { entries = list(d); } catch { return; }
|
|
157
|
+
for (const e of entries) {
|
|
158
|
+
if (e.isDir) { if (depth < 6 && !SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) walk(join(d, e.name), depth + 1); } else if (BUILD_FILE.test(e.name)) builds.push(join(d, e.name));
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
walk(dir, 0);
|
|
162
|
+
for (const file of builds.sort()) {
|
|
163
|
+
const deps = parseBuildDependencies(read(file));
|
|
164
|
+
if (!deps.length) continue;
|
|
165
|
+
const projectDir = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : ".";
|
|
166
|
+
const lockFile = join(projectDir, "gradle.lockfile");
|
|
167
|
+
const locks = new Map(); let any = false;
|
|
168
|
+
if (exists(lockFile)) { parseLockfile(read(lockFile), locks); any = true; }
|
|
169
|
+
const legacy = join(projectDir, "gradle/dependency-locks");
|
|
170
|
+
try { for (const e of list(legacy)) if (!e.isDir && e.name.endsWith(".lockfile")) { parseLockfile(read(`${legacy}/${e.name}`), locks); any = true; } } catch { /* no legacy lock directory */ }
|
|
171
|
+
if (!any) { out.push(`${file}: ${deps.length} declared ${deps.length === 1 ? "dependency" : "dependencies"} and no ${lockFile}: ${NOT_SET_UP}`); continue; }
|
|
172
|
+
const expand = [];
|
|
173
|
+
for (const d of deps) {
|
|
174
|
+
const at = `${file}:${d.line}`;
|
|
175
|
+
if (d.kind === "unreadable") { out.push(`${at}: ${d.config} dependency cannot be read literally (${d.text})`); continue; }
|
|
176
|
+
if (d.kind === "gav") {
|
|
177
|
+
if (/\$|\{/.test(d.group + d.name)) { out.push(`${at}: ${d.config} dependency group or name is interpolated, which cannot be read literally`); continue; }
|
|
178
|
+
expand.push({ at, module: `${d.group}:${d.name}`, version: d.version });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!catalog) { out.push(`${at}: ${d.config} uses libs.${d.alias} but ${catalogFile} does not exist`); continue; }
|
|
182
|
+
if (d.kind === "bundle") {
|
|
183
|
+
const key = [...catalog.bundles.keys()].find((k) => aliasKey(k) === aliasKey(d.alias));
|
|
184
|
+
if (!key) { out.push(`${at}: libs.bundles.${d.alias} is not in ${catalogFile} [bundles]`); continue; }
|
|
185
|
+
for (const a of catalog.bundles.get(key)) { const lib = catalog.libraries.get(a); if (lib) expand.push({ at, module: lib.module, version: lib.version, via: `libs.bundles.${d.alias}` }); else out.push(`${at}: bundle ${key} names ${a}, which [libraries] does not define`); }
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const key = [...catalog.libraries.keys()].find((k) => aliasKey(k) === aliasKey(d.alias));
|
|
189
|
+
if (!key) { out.push(`${at}: libs.${d.alias} is not in ${catalogFile} [libraries]`); continue; }
|
|
190
|
+
const lib = catalog.libraries.get(key);
|
|
191
|
+
expand.push({ at, module: lib.module, version: lib.version, via: `libs.${d.alias}` });
|
|
192
|
+
}
|
|
193
|
+
for (const e of expand) {
|
|
194
|
+
const why = lockedProblem(locks.get(e.module), e.version);
|
|
195
|
+
if (why) out.push(`${e.at}: ${e.module}${e.version ? `:${e.version}` : ""}${e.via ? ` (${e.via})` : ""} ${why} in ${lockFile}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Repo-relative Gradle build root for a changed file: the nearest ancestor with settings.gradle[.kts], else the nearest with build.gradle[.kts]. */
|
|
202
|
+
export function gradleOwnerOf(file, { exists, ancestorDirs }) {
|
|
203
|
+
const dirs = ancestorDirs(file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : ".").reverse();
|
|
204
|
+
const has = (d, names) => names.some((n) => exists(join(d, n)));
|
|
205
|
+
return dirs.find((d) => has(d, ["settings.gradle", "settings.gradle.kts"])) ?? dirs.find((d) => has(d, ["build.gradle", "build.gradle.kts"])) ?? null;
|
|
206
|
+
}
|
package/heavy-command.mjs
CHANGED
|
@@ -221,12 +221,43 @@ function spawnOwnerWatcher(details) {
|
|
|
221
221
|
watcher.unref();
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Whether `right-release build` takes the host-wide heavy-work slot for the whole run (rightkit#1367 step 2). On a broker host every compile, Swift and
|
|
226
|
+
* xcodebuild step, pnpm and node step goes through build-launch and becomes a broker job (Cargo, SwiftPM, native iOS alike), so the broker's scheduler and
|
|
227
|
+
* resource budget admit the work: no slot for the run and no memory or CPU wait. What the release runs itself, outside build-launch, is local work the broker
|
|
228
|
+
* never sees (`localSteps`: its own `pnpm install`); the caller takes the slot around just those steps. A direct host takes the slot for the whole run. A
|
|
229
|
+
* parent that already owns one (RIGHTSUITE_HEAVY_WORK_OWNER=1) is inherited, never taken twice.
|
|
230
|
+
* @returns {{ take: boolean, localSteps: boolean, inherited: boolean, skipReason: string | null }}
|
|
231
|
+
*/
|
|
232
|
+
export function heavySlotDecision({ env = process.env, brokerHost = false } = {}) {
|
|
233
|
+
if (env.RIGHTSUITE_HEAVY_WORK_OWNER === "1") return { take: false, localSteps: false, inherited: true, skipReason: null };
|
|
234
|
+
if (brokerHost) return { take: false, localSteps: true, inherited: false, skipReason: "broker host: build-launch makes the build steps broker jobs, which the broker's scheduler admits; the slot is held only around the release's own local steps" };
|
|
235
|
+
return { take: true, localSteps: false, inherited: false, skipReason: null };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The slot decision and the slot it asks for, in one step: what a release run takes before it starts. `acquire` is a seam for tests. */
|
|
239
|
+
export function acquireRunHeavySlot({ env = process.env, brokerHost = false, acquire = acquireHeavyWorkSlot, ...options } = {}) {
|
|
240
|
+
const decision = heavySlotDecision({ env, brokerHost });
|
|
241
|
+
return { decision, slot: decision.take ? acquire(options) : null };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Runs the local step `run` holding the heavy-work slot when the decision says the run holds none but has local steps (a broker host: its own `pnpm install` is a process
|
|
246
|
+
* the broker never sees); otherwise `run` runs as it is (the run holds the slot, or inherits one). `onSlot(slot)` is told when the slot is taken and `onSlot(null)` when it ends.
|
|
247
|
+
*/
|
|
248
|
+
export async function withLocalHeavySlot(decision, run, { acquire = acquireHeavyWorkSlot, onSlot = () => {}, ...options } = {}) {
|
|
249
|
+
if (!decision?.localSteps) return run(null);
|
|
250
|
+
const slot = acquire(options);
|
|
251
|
+
onSlot(slot);
|
|
252
|
+
try { return await run(slot); } finally { slot.release(); onSlot(null); }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function summarizePreBrokerWaits({ heavySlot, suiteSlot, heavyOwnerInherited = false, heavySkipped = null } = {}) {
|
|
225
256
|
const heavy = heavySlot?.waits ?? null;
|
|
226
257
|
const suite = suiteSlot?.waits ?? null;
|
|
227
258
|
const layers = [
|
|
228
259
|
heavy ? { layer: "heavy-work-slot", ms: (heavy.slotWaitMs ?? 0) + (heavy.resourceWaitMs ?? 0), slotWaitMs: heavy.slotWaitMs ?? 0, resourceWaitMs: heavy.resourceWaitMs ?? 0, blockers: heavy.slotBlockers ?? [], resourceBlockers: heavy.resourceBlockers ?? [] }
|
|
229
|
-
: { layer: "heavy-work-slot", ms: 0, skipped: heavyOwnerInherited ? "slot owned by parent heavy-command" : "not acquired" },
|
|
260
|
+
: { layer: "heavy-work-slot", ms: 0, skipped: heavyOwnerInherited ? "slot owned by parent heavy-command" : heavySkipped ?? "not acquired" },
|
|
230
261
|
suite ? { layer: "suite-build-slot", ms: suite.waitMs ?? 0, blockers: suite.blockers ?? [] } : { layer: "suite-build-slot", ms: 0, skipped: "inactive (non-shared cache mode)" },
|
|
231
262
|
];
|
|
232
263
|
const totalMs = layers.reduce((sum, layer) => sum + layer.ms, 0);
|
package/linux-artifacts.mjs
CHANGED
|
@@ -91,7 +91,8 @@ export function packLinuxArtifacts({ root, archive, app, version, commit, split,
|
|
|
91
91
|
const names = archive.include.map((item) => path.basename(item));
|
|
92
92
|
const dup = names.find((n, i) => names.indexOf(n) !== i);
|
|
93
93
|
if (dup) throw new Error(`linux artifacts: two include entries share the base name ${dup}; the archive is flat`);
|
|
94
|
-
|
|
94
|
+
// The archive is named relative to the working directory, and `-C` directories use forward slashes: a GNU tar (Git for Windows) reads `C:\\...` before the first colon as `host:path` and fails.
|
|
95
|
+
const tar = run("tar", ["-czf", file, ...archive.include.flatMap((item) => ["-C", path.resolve(root, path.dirname(item)).replaceAll("\\", "/"), path.basename(item)])], { cwd: outDir, stdio: "inherit" });
|
|
95
96
|
if (tar.status !== 0) throw new Error(`linux artifacts: tar failed (${tar.status})`);
|
|
96
97
|
const bytes = fs.readFileSync(target);
|
|
97
98
|
const entry = { file, sha256: createHash("sha256").update(bytes).digest("hex"), size: bytes.length, glibc };
|
package/linux-setup.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Idempotent Linux host provisioning for apps gated by `rightkit-gate` (rightkit#1365): reads the app's pins, installs Node
|
|
2
|
+
// (official tarball into ~/.local), pnpm (corepack, else npm into ~/.local), Rust (rustup), then fills the pnpm store for every
|
|
3
|
+
// committed lockroot with `pnpm fetch` against a throwaway virtual store, so an existing node_modules is never cleared.
|
|
4
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { join, relative, resolve } from "node:path";
|
|
7
|
+
import { parse as parseToml } from "smol-toml";
|
|
8
|
+
|
|
9
|
+
const EXACT = /^\d+\.\d+\.\d+$/;
|
|
10
|
+
|
|
11
|
+
export function readPins(root, read = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null)) {
|
|
12
|
+
const pkgText = read(join(root, "package.json"));
|
|
13
|
+
const pkg = pkgText ? JSON.parse(pkgText) : {};
|
|
14
|
+
const nodeFile = read(join(root, ".node-version"))?.trim().replace(/^v/, "");
|
|
15
|
+
const node = nodeFile || String(pkg.engines?.node ?? "").trim() || null;
|
|
16
|
+
const pm = /^pnpm@(\d+\.\d+\.\d+)/.exec(pkg.packageManager ?? "");
|
|
17
|
+
const tomlText = read(join(root, "rust-toolchain.toml"));
|
|
18
|
+
const rust = tomlText ? (parseToml(tomlText).toolchain?.channel ?? null) : null;
|
|
19
|
+
return { node, pnpm: pm ? pm[1] : null, rust };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// "26.8.x" / "26.8" / "26" / "^26.8.0" / "26.8.1" -> newest matching release in the nodejs.org index (array of {version}).
|
|
23
|
+
export function resolveNodeVersion(spec, index) {
|
|
24
|
+
if (!spec) return null;
|
|
25
|
+
const clean = spec.replace(/^[v^~=]+/, "").replace(/\.x\b/g, "").replace(/\.\*/g, "");
|
|
26
|
+
if (EXACT.test(clean)) return clean;
|
|
27
|
+
const m = /^(\d+)(?:\.(\d+))?/.exec(clean);
|
|
28
|
+
if (!m) throw new Error(`cannot resolve Node pin ${JSON.stringify(spec)}`);
|
|
29
|
+
const found = index.map((e) => e.version.replace(/^v/, "")).filter((v) => {
|
|
30
|
+
const [a, b] = v.split(".");
|
|
31
|
+
return a === m[1] && (m[2] === undefined || b === m[2]);
|
|
32
|
+
}).sort((x, y) => x.split(".").map(Number).reduce((d, n, i) => d || n - Number(y.split(".")[i]), 0) * -1);
|
|
33
|
+
if (!found.length) throw new Error(`no Node release matches ${JSON.stringify(spec)}`);
|
|
34
|
+
return found[0];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function nodeArch(arch = process.arch) {
|
|
38
|
+
const a = { x64: "x64", arm64: "arm64", arm: "armv7l" }[arch];
|
|
39
|
+
if (!a) throw new Error(`unsupported CPU architecture ${arch}`);
|
|
40
|
+
return a;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function findLockroots(root, readDir = (d) => readdirSync(d, { withFileTypes: true })) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const walk = (dir) => {
|
|
46
|
+
const entries = readDir(dir);
|
|
47
|
+
if (entries.some((e) => e.isFile() && e.name === "pnpm-lock.yaml")) out.push(relative(root, dir) || ".");
|
|
48
|
+
for (const e of entries) {
|
|
49
|
+
if (e.isDirectory() && !["node_modules", ".git", "target"].includes(e.name) && !e.name.startsWith(".")) walk(join(dir, e.name));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk(root);
|
|
53
|
+
return out.sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// run(cmd, args, opts) -> {status, stdout}; fetchText(url) -> string. Both injectable so the plan is testable offline.
|
|
57
|
+
export function setup({ root, home = homedir(), arch = process.arch, run, fetchText, log = () => {}, env = process.env, dryRun = false } = {}) {
|
|
58
|
+
root = resolve(root);
|
|
59
|
+
const pins = readPins(root);
|
|
60
|
+
const bin = join(home, ".local", "bin");
|
|
61
|
+
const steps = [];
|
|
62
|
+
const sh = (cmd, args, opts = {}) => {
|
|
63
|
+
steps.push([cmd, ...args].join(" "));
|
|
64
|
+
if (dryRun) return { status: 0, stdout: "" };
|
|
65
|
+
const r = run(cmd, args, { cwd: root, ...opts, env: { ...env, ...opts.env, PATH: `${bin}:${join(home, ".cargo", "bin")}:${opts.env?.PATH ?? env.PATH ?? ""}` } });
|
|
66
|
+
if (r.status !== 0) throw new Error(`${cmd} ${args.join(" ")} failed (${r.status})`);
|
|
67
|
+
return r;
|
|
68
|
+
};
|
|
69
|
+
const probe = (cmd, args) => { try { return (run(cmd, args, { cwd: root, env: { ...env, PATH: `${bin}:${join(home, ".cargo", "bin")}:${env.PATH ?? ""}` } }).stdout ?? "").trim(); } catch { return ""; } };
|
|
70
|
+
|
|
71
|
+
if (pins.node) {
|
|
72
|
+
const exact = EXACT.test(pins.node.replace(/^v/, ""));
|
|
73
|
+
const version = dryRun && !exact ? null : resolveNodeVersion(pins.node, exact ? [] : JSON.parse(fetchText("https://nodejs.org/dist/index.json")));
|
|
74
|
+
if (dryRun && !version) steps.push(`(install Node ${pins.node})`);
|
|
75
|
+
if (version && probe("node", ["--version"]) !== `v${version}`) {
|
|
76
|
+
const name = `node-v${version}-linux-${nodeArch(arch)}`;
|
|
77
|
+
const dest = join(home, ".local");
|
|
78
|
+
log(`Node ${version}`);
|
|
79
|
+
sh("sh", ["-c", `set -e; mkdir -p "${dest}/lib" "${bin}"; cd "$(mktemp -d)"; curl -fsSLO https://nodejs.org/dist/v${version}/${name}.tar.xz; curl -fsSL https://nodejs.org/dist/v${version}/SHASUMS256.txt | grep " ${name}.tar.xz$" | sha256sum -c -; rm -rf "${dest}/lib/${name}"; tar -xJf ${name}.tar.xz -C "${dest}/lib"; for b in node npm npx corepack; do [ -e "${dest}/lib/${name}/bin/$b" ] && ln -sfn "${dest}/lib/${name}/bin/$b" "${bin}/$b"; done`]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (pins.pnpm && probe("pnpm", ["--version"]) !== pins.pnpm) {
|
|
83
|
+
log(`pnpm ${pins.pnpm}`);
|
|
84
|
+
if (dryRun || probe("corepack", ["--version"])) {
|
|
85
|
+
sh("corepack", ["enable", "--install-directory", bin]);
|
|
86
|
+
sh("corepack", ["prepare", `pnpm@${pins.pnpm}`, "--activate"]);
|
|
87
|
+
} else sh("npm", ["install", "-g", `pnpm@${pins.pnpm}`, "--prefix", join(home, ".local")]);
|
|
88
|
+
}
|
|
89
|
+
if (pins.rust) {
|
|
90
|
+
if (!probe("rustup", ["--version"])) {
|
|
91
|
+
log("rustup");
|
|
92
|
+
sh("sh", ["-c", "curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none --no-modify-path"]);
|
|
93
|
+
}
|
|
94
|
+
log(`Rust ${pins.rust}`);
|
|
95
|
+
sh("rustup", ["toolchain", "install", "--profile", "minimal"]); // reads rust-toolchain.toml from the app root
|
|
96
|
+
}
|
|
97
|
+
const lockroots = findLockroots(root);
|
|
98
|
+
for (const lr of lockroots) {
|
|
99
|
+
const dir = resolve(root, lr);
|
|
100
|
+
log(`pnpm store: ${lr}`);
|
|
101
|
+
// The throwaway virtual store keeps `pnpm fetch` from touching the root's node_modules.
|
|
102
|
+
const vstore = dryRun ? join(tmpdir(), "rk-vstore") : mkdtempSync(join(tmpdir(), "rk-vstore-"));
|
|
103
|
+
try { sh("pnpm", ["fetch", "--virtual-store-dir", vstore], { cwd: dir }); } finally { if (!dryRun) rmSync(vstore, { recursive: true, force: true }); }
|
|
104
|
+
}
|
|
105
|
+
return { pins, lockroots, steps };
|
|
106
|
+
}
|
package/local-publish.mjs
CHANGED
|
@@ -35,9 +35,11 @@ import { requireDoorRun, gateStateDir } from "./gate-stages.mjs";
|
|
|
35
35
|
export const LOCAL_OPERATOR = "agent (local-build grant)";
|
|
36
36
|
export const LOCAL_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run";
|
|
37
37
|
export const LOCAL_WIN32_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); published from the Windows host, the macOS run is not part of this receipt";
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
export const LOCAL_LINUX_HOST_POLICY_REASON = "GATE-CONTRACT Linux direct origin (GB-29, rightkit#1341): compiled and qualified locally with no broker; the macOS and Windows qualification is the publish run's own native verify jobs";
|
|
39
|
+
// The hosts `--local` accepts and the host policy each records in the evidence. A Linux direct origin records "linux-direct": it is
|
|
40
|
+
// not a single-host policy (isDarwinOnlyPolicy / isWin32OnlyPolicy stay false), so both native hosts are still required on CI.
|
|
41
|
+
export const LOCAL_HOST_POLICIES = Object.freeze({ darwin: "darwin-only", win32: "win32-only", linux: "linux-direct" });
|
|
42
|
+
const localHostPolicyReason = (platform) => (platform === "win32" ? LOCAL_WIN32_HOST_POLICY_REASON : platform === "linux" ? LOCAL_LINUX_HOST_POLICY_REASON : LOCAL_HOST_POLICY_REASON);
|
|
41
43
|
const STEP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
42
44
|
|
|
43
45
|
export class LocalPublishError extends Error {
|
|
@@ -407,12 +409,15 @@ function testEnv(deps, isAssetGated) {
|
|
|
407
409
|
return { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
|
|
408
410
|
}
|
|
409
411
|
|
|
410
|
-
function assertLocalHost(deps, workspaceRoot, kind) {
|
|
412
|
+
function assertLocalHost(deps, workspaceRoot, kind, { qualifyOnly = false, unit = null } = {}) {
|
|
411
413
|
const platform = deps.platform ?? process.platform;
|
|
412
414
|
if (!(deps.localBuildGranted ?? localBuildGranted)(workspaceRoot)) refuse(`--local needs the local-build grant: a committed ${LOCAL_BUILD_FILE} (allow "build-test-publish") naming this repository at ${workspaceRoot}`);
|
|
413
|
-
if (!Object.hasOwn(LOCAL_HOST_POLICIES, platform)) refuse(`--local
|
|
414
|
-
// The npm
|
|
415
|
-
|
|
415
|
+
if (!Object.hasOwn(LOCAL_HOST_POLICIES, platform)) refuse(`--local runs on the Mac (host policy: darwin-only), Windows (host policy: win32-only) or a Linux direct origin (linux-direct); this host is ${platform}`);
|
|
416
|
+
// The npm upload reads the registry token from the macOS Keychain: Mac only.
|
|
417
|
+
// A Linux direct origin only qualifies (no token; the upload is publish.yml, GB-29), so it may qualify every npm package, @rightkit/release
|
|
418
|
+
// included: its Developer-ID-signed helper is built and signed hosted (publish.yml build-swap/sign-swap), never on the origin.
|
|
419
|
+
const linuxNpmQualify = platform === "linux" && qualifyOnly;
|
|
420
|
+
if (kind === "npm" && platform !== "darwin" && !linuxNpmQualify) refuse(`publish npm --local needs the macOS Keychain npm token and runs on the Mac only; Windows publishes crates (publish cargo --local); this host is ${platform}`);
|
|
416
421
|
return platform;
|
|
417
422
|
}
|
|
418
423
|
|
|
@@ -582,7 +587,8 @@ async function* publishUnitStages(parsed, deps, batch) {
|
|
|
582
587
|
|
|
583
588
|
|
|
584
589
|
// 1. Preconditions, fail closed.
|
|
585
|
-
const platform = assertLocalHost(deps, workspaceRoot, kind);
|
|
590
|
+
const platform = assertLocalHost(deps, workspaceRoot, kind, { qualifyOnly: parsed.qualifyOnly, unit: parsed.unit });
|
|
591
|
+
if (platform === "linux" && !parsed.qualifyOnly) refuse("a Linux direct origin qualifies only (--qualify-only); the upload is publish.yml with admission=direct (GB-29, rightkit#1341)");
|
|
586
592
|
if (parsed.qualifyOnly && fs.existsSync(parsed.bundleDir)) refuse(`qualification bundle already exists: ${parsed.bundleDir}`);
|
|
587
593
|
const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
|
|
588
594
|
const unitPath = path.join(workspaceRoot, dir);
|
|
@@ -691,7 +697,8 @@ async function* publishUnitStages(parsed, deps, batch) {
|
|
|
691
697
|
const cleanup = () => fs.rmSync(scratch, { recursive: true, force: true });
|
|
692
698
|
try {
|
|
693
699
|
let artifactFile; const packCommands = [];
|
|
694
|
-
if (kind === "npm" && unit === SWAP_HELPER_PACKAGE) {
|
|
700
|
+
if (kind === "npm" && unit === SWAP_HELPER_PACKAGE && platform !== "linux") {
|
|
701
|
+
// A Linux direct origin skips this: it packs without the helper and the hosted sign-swap job binds the sealed one.
|
|
695
702
|
// #258: the package ships a Developer-ID-signed bin/darwin/rightkit-swap; build, sign and verify it before packing (fails closed). The
|
|
696
703
|
// helper and its signing receipt are written into the unit's own directory of the snapshot (packages/release/bin/darwin, both ignored by
|
|
697
704
|
// packages/release/.gitignore): declared stage outputs of this stage, recorded by the gate's stage-output ledger (stage-ledger.mjs) like
|
|
@@ -782,7 +789,10 @@ async function* publishUnitStages(parsed, deps, batch) {
|
|
|
782
789
|
fs.mkdirSync(path.dirname(evidencePath), { recursive: true });
|
|
783
790
|
fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, { flag: parsed.qualifyOnly ? "wx" : "w" });
|
|
784
791
|
log(`[right-release] wrote ${evidencePath} (artifact sha256 ${artifactSha256})`);
|
|
785
|
-
|
|
792
|
+
// The gate reads source identity through the same git the stages use (a unit snapshot has no .git; sourceGit answers for its commit).
|
|
793
|
+
// A Linux direct origin only ever qualifies (GB-29): its bundle proves the Linux run, and the upload's own native jobs prove Mac and Windows.
|
|
794
|
+
const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath, artifactBytes: fs.readFileSync(artifactFile),
|
|
795
|
+
...(deps.git ? { git: deps.git } : {}), ...(platform === "linux" && parsed.qualifyOnly ? { directLinux: true } : {}) };
|
|
786
796
|
(deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
|
|
787
797
|
|
|
788
798
|
if (parsed.qualifyOnly) {
|
|
@@ -824,6 +834,7 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
824
834
|
const readJson = deps.readJson ?? defaultReadJson;
|
|
825
835
|
const run = deps.run ?? defaultRunAsync;
|
|
826
836
|
const platform = assertLocalHost(deps, workspaceRoot, "cargo");
|
|
837
|
+
if (platform === "linux") refuse("a Linux direct origin qualifies one unit at a time; batch publish runs on the Mac or Windows (GB-29, rightkit#1341)");
|
|
827
838
|
|
|
828
839
|
// 1. The crate list and its dependency layers.
|
|
829
840
|
const requested = parsed.batch.file ? parseCrateLayers((deps.readText ?? ((file) => fs.readFileSync(file, "utf8")))(parsed.batch.file)) : [parsed.batch.crates];
|
package/local-qualify.mjs
CHANGED
|
@@ -31,7 +31,8 @@ import zlib from "node:zlib";
|
|
|
31
31
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
32
32
|
import { spawnSync } from "./background-process.mjs";
|
|
33
33
|
import { managedRuntimeClient, spawnCollect } from "./managed-cargo.mjs";
|
|
34
|
-
import { planScans, runScans } from "./deterministic-scans.mjs";
|
|
34
|
+
import { defaultSpawnAsync, planScans, runScans } from "./deterministic-scans.mjs";
|
|
35
|
+
import { createBrokerLive } from "./broker-admission.mjs";
|
|
35
36
|
import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
|
|
36
37
|
import { deriveCrateLayers } from "./local-publish.mjs";
|
|
37
38
|
import { RUNTIME_BUDGET_CRATES } from "./runtime-budget-crates.mjs";
|
|
@@ -409,10 +410,15 @@ async function localScanRecord({ root, commit, crates, jobs, scratch, deps, log
|
|
|
409
410
|
// Repository-level scans (workflows, YAML) plus every scan of the units, each in its own crate workspace (lock, deny, audit); other crates and npm packages stay out of scope.
|
|
410
411
|
const files = tracked.filter((file) => (!file.startsWith("crates/") && !file.startsWith("packages/")) || unitDirs.some((dir) => file.startsWith(dir)));
|
|
411
412
|
const crateFeatures = (deps.readJson ?? defaultReadJson)(path.join(repo, "ci", "gate.config.json"))?.rust ?? {};
|
|
412
|
-
const
|
|
413
|
+
const hostCommand = detectCargoCommand(deps);
|
|
414
|
+
const plan = (deps.planScans ?? planScans)({ changed: files, contentRoot: repo, liveRoot: root, includeTests: false, localCompile: true, cargoCommand: hostCommand, crateFeatures, readBytes: (file) => fs.readFileSync(file) });
|
|
413
415
|
const observedTools = modules.observeToolVersions();
|
|
414
416
|
log(`[local-qualify] scans: ${plan.scans.length} planned over ${files.length} files (class A, then class B through the broker)`);
|
|
415
|
-
|
|
417
|
+
// detectCargoCommand above accepts only `rustright` or the managed shim on PATH, so this is always a broker host: the broker orders the carried class B scans (rightkit#1367 step 2).
|
|
418
|
+
// The client the live reader asks through: the managed runtime client, else the `rustright` the scans themselves run through.
|
|
419
|
+
const client = managedRuntimeClient(deps.env ?? process.env, deps.platform ?? process.platform) ?? (hostCommand[0] === "rustright" ? "rustright" : null);
|
|
420
|
+
const brokerLive = client ? createBrokerLive({ client, env: deps.env ?? process.env, spawn: defaultSpawnAsync, platform: deps.platform ?? process.platform }) : null;
|
|
421
|
+
const result = await (deps.runScans ?? runScans)(plan, { classes: ["A", "B"], jobs: Math.max(1, Number(jobs ?? 4) || 4), brokerHost: true, ...(brokerLive ? { brokerLive } : {}), onResult: (row, progress) => log(`[local-qualify] scan [${progress.done}/${progress.total}] ${row.status.padEnd(7)} ${row.id}`) });
|
|
416
422
|
// Every unit is bound to its own crate workspace lock (#1285): crates/<x>/Cargo.lock, no root lock.
|
|
417
423
|
const cargoLocks = Object.fromEntries([...crates].sort().map((name) => [name, { path: `crates/${name}/Cargo.lock`, sha256: sha256(fs.readFileSync(path.join(root, "crates", name, "Cargo.lock"))) }]));
|
|
418
424
|
return modules.buildLocalScanRecord({ result, planned: plan.scans, sourceCommit: commit, headCommit: commit, cargoLocks, observedTools, units: crates, files: files.length });
|