@rightkit/release 0.6.13 → 0.6.14
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 +49 -1
- package/adapter-env.mjs +165 -0
- package/android-ndk.mjs +5 -1
- package/android-release.mjs +73 -8
- package/app-check.mjs +22 -8
- package/app-closure.mjs +214 -0
- package/assemble-app-command.mjs +24 -7
- package/atomic-write.mjs +57 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1190 -337
- package/broker-client.mjs +106 -0
- package/broker-exception.mjs +211 -0
- package/broker-handshake.mjs +160 -0
- package/broker-pins.mjs +27 -8
- package/broker-wire.mjs +262 -0
- package/build-launch.mjs +364 -12
- package/build-ownership.mjs +117 -49
- package/build-release.mjs +3 -2
- package/cache-command.mjs +7 -2
- package/candidate-build-receipt.mjs +96 -0
- package/candidate-receipt-command.mjs +29 -0
- package/cargo-contract.mjs +4 -3
- package/cargo-ledger.mjs +1 -1
- package/cargo-license-notices.mjs +2 -2
- package/cargo-shapes.mjs +332 -0
- package/cargo-target-shim.mjs +44 -5
- package/cargo-target.mjs +5 -0
- package/carried-adapter-run.mjs +31 -0
- package/carried-cargo-run.mjs +9 -0
- package/carried-cargo.mjs +237 -0
- package/carrier-census.mjs +124 -0
- package/class-a-launch-guard.mjs +266 -33
- package/cli/right-release.mjs +2 -0
- package/clippy-route.mjs +23 -11
- package/compile-inputs.mjs +9 -12
- package/control-plane.mjs +18 -0
- package/deps-cargo-lock.mjs +208 -0
- package/deps.mjs +5 -0
- package/deterministic-scans.mjs +273 -44
- package/development-signing.mjs +14 -1
- package/development.mjs +109 -24
- package/direct-origin.mjs +107 -0
- package/disk-command.mjs +1 -0
- package/disk-policy.mjs +18 -3
- package/freshness-census.mjs +206 -58
- package/gate-executor.mjs +30 -12
- package/gate-stages.mjs +93 -16
- package/github-release.mjs +2 -1
- package/github-upload-gate.mjs +2 -1
- package/install-tauri-authenticode.mjs +346 -0
- package/ios-dev.mjs +77 -23
- package/ios-package-recipe.mjs +100 -41
- package/linux-artifacts.mjs +17 -2
- package/linux-qualifier.mjs +83 -0
- package/local-publish.mjs +36 -14
- package/local-runtime.mjs +2 -2
- package/lock-identity.mjs +138 -0
- package/lsclean.sh +0 -0
- package/managed-cargo.mjs +151 -44
- package/managed-roots.mjs +130 -12
- package/media-sidecar-catalog.mjs +1 -1
- package/native-adapters.mjs +411 -0
- package/native-helpers.mjs +26 -18
- package/native-tool-planning.mjs +94 -0
- package/ownership-mark.mjs +182 -0
- package/pack-digest.mjs +122 -0
- package/package.json +12 -12
- package/platform-dir.mjs +14 -0
- package/pnpm-adapter-grammar.mjs +226 -0
- package/progress-control.mjs +7 -2
- package/provenance-real-receipt.pins.json +7 -0
- package/provenance-verify.mjs +262 -1
- package/publication-outcomes.mjs +21 -0
- package/publish-update.mjs +2 -1
- package/qa-journeys-prebuilt-run.mjs +18 -0
- package/qa-journeys-prebuilt.mjs +148 -0
- package/qa-snapshot-run.mjs +53 -0
- package/qa-snapshot.mjs +164 -0
- package/qualification-verify.mjs +144 -0
- package/qualify-reuse.mjs +38 -0
- package/recipe-build.mjs +16 -3
- package/release-qa-audit.mjs +2 -2
- package/release-qa-command.mjs +2 -1
- package/release-qa-receipt.mjs +3 -2
- package/release-state.mjs +3 -2
- package/release.mjs +14 -3
- package/rightkit-drift.mjs +27 -5
- package/rightkit-versions.json +14 -10
- package/rustright-names.mjs +32 -0
- package/scan-tool-enrolment.mjs +167 -0
- package/sign-windows.mjs +212 -112
- package/signing-files.mjs +35 -0
- package/signing-identity.mjs +7 -0
- package/snapshot-broker.mjs +528 -57
- package/snapshot-check.mjs +95 -11
- package/snapshot-metadata.mjs +123 -0
- package/snapshot-request.mjs +389 -96
- package/stage-binary.mjs +22 -4
- package/stage-commands.mjs +56 -13
- package/stage-sidecar.mjs +93 -21
- package/suite-signing-policy.mjs +657 -0
- package/suite-signing.mjs +223 -0
- package/swift-scratch.mjs +25 -0
- package/tauri-locked.mjs +25 -0
- package/test-command.mjs +164 -29
- package/unit-gate.mjs +201 -31
- package/upload-large.mjs +0 -0
- package/web-build.mjs +2 -2
- package/win-package-recipe.mjs +26 -2
package/freshness-census.mjs
CHANGED
|
@@ -12,6 +12,15 @@
|
|
|
12
12
|
// Counts come from the target directory the way the broker takes them (rustright-policy cargo_freshness.rs): a unit is one directory
|
|
13
13
|
// of a `.fingerprint`; a unit whose newest file is at or after the census start was rebuilt (`notFresh`, a new unit included), the
|
|
14
14
|
// others were reused (`fresh`). Judging unexplained rebuilds belongs to item 11; this module only measures and records.
|
|
15
|
+
//
|
|
16
|
+
// GB-68 (BROKER-CONTRACT section 16; the shared vector test-support/vectors/freshness-evidence-vectors.v1.json, held by freshness-vectors.test.mjs):
|
|
17
|
+
// - the log reader reads only the ROOT's lines: those before Cargo's first execution status word (`Compiling`, `Checking`, ... `Finished`); nested
|
|
18
|
+
// Cargo chatter after it is neither counted nor stripped; a `failed to read ...fingerprint...` reason followed by a `Caused by:` not-found cause
|
|
19
|
+
// (Unix `os error 2`, Windows `os error 3`) is `FingerprintMissing`, any other cause stays `unexplained`; every reason carries its `cause`;
|
|
20
|
+
// - the census reads the SELECTED scope only: `<target>/<profile>/.fingerprint` and, for a `--target` triple, `<target>/<triple>/<profile>/.fingerprint`
|
|
21
|
+
// (never every profile or triple, never `doc` or `package`), taken from the compiling process's own arguments and environment;
|
|
22
|
+
// - a fingerprint directory or unit that exists and cannot be read is `fingerprint-census-read-failed`: unavailable, never an empty census and never a
|
|
23
|
+
// first build. Only an absent directory is empty.
|
|
15
24
|
import fs from "node:fs";
|
|
16
25
|
import path from "node:path";
|
|
17
26
|
|
|
@@ -28,6 +37,86 @@ export const CENSUS_ENTRY_CAP = 250_000;
|
|
|
28
37
|
// A held partial line longer than this is not a log line.
|
|
29
38
|
const PARTIAL_CAP = 64 * 1024;
|
|
30
39
|
|
|
40
|
+
/** Cargo's execution-phase status words: the first one ends the window in which fingerprint lines are the root's (pre-resolution words do not). */
|
|
41
|
+
export const EXECUTION_STATUS = Object.freeze(["Compiling", "Checking", "Documenting", "Fresh", "Dirty", "Running", "Finished", "Scraping"]);
|
|
42
|
+
const EXECUTION_WORDS = new Set(EXECUTION_STATUS);
|
|
43
|
+
|
|
44
|
+
const SOURCE_OR_DEPENDENCY = new Set(["FsStatusOutdated", "PathToSourceChanged", "NumberOfDependenciesChanged", "UnitDependencyNameChanged", "UnitDependencyInfoChanged", "LocalLengthsChanged", "PrecalculatedComponentsChanged", "DepInfoOutputChanged", "RerunIfChangedOutputFileChanged", "RerunIfChangedOutputPathsChanged"]);
|
|
45
|
+
const ENV_RUSTFLAGS_PROFILE_FEATURES_TARGET = new Set(["EnvVarChanged", "EnvVarsChanged", "RustflagsChanged", "ProfileConfigurationChanged", "FeaturesChanged", "DeclaredFeaturesChanged", "TargetConfigurationChanged", "CompileKindChanged", "LocalFingerprintTypeChanged", "FingerprintMethodChanged", "ConfigSettingsChanged"]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The cause a reason's `kind` explains (RustRight cargo_evidence.rs `cause_of`; the vector's `causes`): the first build, a missing fingerprint (named apart, and still unexplained
|
|
49
|
+
* by itself), a toolchain change, a source or dependency change, an environment, rustflags, profile, features or target change. Any other kind, one Cargo adds later included, is `unexplained`.
|
|
50
|
+
*/
|
|
51
|
+
export function causeOf(kind) {
|
|
52
|
+
if (kind === "FreshBuild") return "first-build";
|
|
53
|
+
if (kind === "FingerprintMissing") return "fingerprint-missing";
|
|
54
|
+
if (kind === "RustcChanged") return "toolchain-changed";
|
|
55
|
+
if (SOURCE_OR_DEPENDENCY.has(kind)) return "source-or-dependency-changed";
|
|
56
|
+
if (ENV_RUSTFLAGS_PROFILE_FEATURES_TARGET.has(kind)) return "env-rustflags-profile-features-or-target-changed";
|
|
57
|
+
return "unexplained";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a `Caused by:` line says the fingerprint file was absent: `No such file or directory (os error 2)` on Unix, `... (os error 2)` or `... path specified. (os error 3)` on Windows. */
|
|
61
|
+
const missingFingerprint = (cause) => cause.endsWith("(os error 2)") || cause.endsWith("(os error 3)");
|
|
62
|
+
|
|
63
|
+
/** The profile directory a command line selects (RustRight `cargo_profile_directory`): `--release`/`-r`, `--profile X` (`dev` and `test` are `debug`, `bench` is `release`, a custom name is itself); nothing after `--`. */
|
|
64
|
+
export function cargoProfileDirectory(argv) {
|
|
65
|
+
let profile = null; let release = false;
|
|
66
|
+
const words = Array.isArray(argv) ? argv.map(String) : [];
|
|
67
|
+
for (let i = 0; i < words.length && words[i] !== "--"; i += 1) {
|
|
68
|
+
const item = words[i];
|
|
69
|
+
if (item === "--release" || item === "-r") release = true;
|
|
70
|
+
else if (item === "--profile") profile = words[i + 1] ?? null;
|
|
71
|
+
else if (item.startsWith("--profile=")) profile = item.slice("--profile=".length);
|
|
72
|
+
}
|
|
73
|
+
if (profile === "dev" || profile === "test") return "debug";
|
|
74
|
+
if (profile === "bench") return "release";
|
|
75
|
+
if (profile !== null) return profile;
|
|
76
|
+
return release ? "release" : "debug";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a command line asks for JSON messages (`--message-format json*` in any spelling, before `--`). */
|
|
80
|
+
export function wantsCargoJson(argv) {
|
|
81
|
+
const words = Array.isArray(argv) ? argv.map(String) : [];
|
|
82
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
83
|
+
const item = words[i];
|
|
84
|
+
if (item === "--") break;
|
|
85
|
+
const value = item === "--message-format" ? words[i + 1] : item.startsWith("--message-format=") ? item.slice("--message-format=".length) : undefined;
|
|
86
|
+
if (value !== undefined && value.split(",").some((segment) => segment.startsWith("json"))) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The triples a command line names with `--target` (before `--`), then `CARGO_BUILD_TARGET` when the line names none. */
|
|
92
|
+
export function cargoTargetTriples(argv, env = {}) {
|
|
93
|
+
const words = Array.isArray(argv) ? argv.map(String) : [];
|
|
94
|
+
const found = [];
|
|
95
|
+
for (let i = 0; i < words.length && words[i] !== "--"; i += 1) {
|
|
96
|
+
const item = words[i];
|
|
97
|
+
const value = item === "--target" ? words[i + 1] : item.startsWith("--target=") ? item.slice("--target=".length) : undefined;
|
|
98
|
+
if (typeof value === "string" && value !== "" && !found.includes(value)) found.push(value);
|
|
99
|
+
}
|
|
100
|
+
if (found.length === 0) {
|
|
101
|
+
const named = Object.entries(env ?? {}).find(([key]) => key.toUpperCase() === "CARGO_BUILD_TARGET");
|
|
102
|
+
if (named && typeof named[1] === "string" && named[1].trim() !== "") for (const t of named[1].trim().split(/\s+/)) if (!found.includes(t)) found.push(t);
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The `.fingerprint` directories one compile selects in a target directory: `<target>/<triple>/<profile>/.fingerprint` for each selected triple, then `<target>/<profile>/.fingerprint`
|
|
109
|
+
* (RustRight `fingerprint_census_start`). Never every profile or triple, never `doc` or `package`.
|
|
110
|
+
*/
|
|
111
|
+
export function selectedFingerprintDirectories(targetDir, { argv = [], triples = [] } = {}) {
|
|
112
|
+
const profile = cargoProfileDirectory(argv);
|
|
113
|
+
const found = [];
|
|
114
|
+
for (const triple of triples) { const dir = path.join(targetDir, triple, profile, ".fingerprint"); if (!found.includes(dir)) found.push(dir); }
|
|
115
|
+
const plain = path.join(targetDir, profile, ".fingerprint");
|
|
116
|
+
if (!found.includes(plain)) found.push(plain);
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
|
|
31
120
|
const stripAnsi = (text) => text.replace(/\u001b\[[^A-Za-z]*[A-Za-z]/g, "");
|
|
32
121
|
const afterPrefix = (message, prefixes) => {
|
|
33
122
|
for (const prefix of prefixes) if (message.startsWith(prefix)) return message.slice(prefix.length);
|
|
@@ -63,6 +152,10 @@ export class FingerprintLog {
|
|
|
63
152
|
this.dirtyUnits = 0;
|
|
64
153
|
this.reasons = [];
|
|
65
154
|
this.truncated = 0;
|
|
155
|
+
// Cargo printed its first execution-phase status line: later fingerprint lines are not the root's (a nested Cargo's).
|
|
156
|
+
this.executing = false;
|
|
157
|
+
// The reason just recorded was Cargo's `failed to read` of a `.fingerprint` file: its `Caused by:` chain follows on the next lines and says whether the file was simply absent.
|
|
158
|
+
this.awaitingCause = null;
|
|
66
159
|
}
|
|
67
160
|
|
|
68
161
|
/** One chunk of standard error in; what the caller sees out. Without stripping that is the chunk itself. */
|
|
@@ -91,8 +184,25 @@ export class FingerprintLog {
|
|
|
91
184
|
return rest;
|
|
92
185
|
}
|
|
93
186
|
|
|
187
|
+
/** Whether `raw` is one of the ROOT's fingerprint lines (counted, and stripped when stripping). */
|
|
94
188
|
#line(raw) {
|
|
95
189
|
const line = stripAnsi(raw.replace(/\s+$/, ""));
|
|
190
|
+
// GB-68: after the first execution status word nothing is the root's: nested Cargo chatter is neither counted nor stripped.
|
|
191
|
+
if (this.executing) { this.awaitingCause = null; return false; }
|
|
192
|
+
const first = line.trim().split(/\s+/)[0];
|
|
193
|
+
if (EXECUTION_WORDS.has(first)) { this.executing = true; return false; }
|
|
194
|
+
// The `Caused by:` chain of a `failed to read` reason: the heading, then the cause, indented. Blank lines inside the chain belong to it.
|
|
195
|
+
if (this.awaitingCause !== null) {
|
|
196
|
+
const text = line.trim();
|
|
197
|
+
if (text === "") return true;
|
|
198
|
+
if (!this.awaitingCause.headingSeen && text === "Caused by:") { this.awaitingCause.headingSeen = true; return true; }
|
|
199
|
+
const { index, headingSeen } = this.awaitingCause;
|
|
200
|
+
this.awaitingCause = null;
|
|
201
|
+
if (headingSeen && /^\s/.test(line)) {
|
|
202
|
+
if (missingFingerprint(text) && this.reasons[index]) { this.reasons[index].kind = "FingerprintMissing"; this.reasons[index].cause = causeOf("FingerprintMissing"); }
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
96
206
|
const at = line.indexOf(FINGERPRINT_TARGET);
|
|
97
207
|
if (at < 0) return false;
|
|
98
208
|
const message = line.slice(at + FINGERPRINT_TARGET.length).replace(/^:+/, "").trim();
|
|
@@ -111,8 +221,11 @@ export class FingerprintLog {
|
|
|
111
221
|
if (this.reasons.length >= REASONS_CAP) { this.truncated += 1; return; }
|
|
112
222
|
const { unit, target } = this.current ?? { unit: "unknown", target: null };
|
|
113
223
|
const chars = Array.from(reason);
|
|
114
|
-
const text = chars.length > REASON_TEXT_CAP ? `${chars.slice(0, REASON_TEXT_CAP).join("")}
|
|
115
|
-
|
|
224
|
+
const text = chars.length > REASON_TEXT_CAP ? `${chars.slice(0, REASON_TEXT_CAP).join("")}\u2026` : reason;
|
|
225
|
+
const kind = reasonKind(reason);
|
|
226
|
+
this.reasons.push({ unit, target, kind, cause: causeOf(kind), reason: text });
|
|
227
|
+
const readsFingerprint = reason.startsWith("failed to read `") && (reason.includes("/.fingerprint/") || reason.includes("\\.fingerprint\\"));
|
|
228
|
+
this.awaitingCause = readsFingerprint ? { index: this.reasons.length - 1, headingSeen: false } : null;
|
|
116
229
|
}
|
|
117
230
|
|
|
118
231
|
/** What another log's reading adds to this one (the census keeps one aggregate over every compiling process). */
|
|
@@ -146,76 +259,108 @@ export function compilesWorkspace(command, args) {
|
|
|
146
259
|
return false;
|
|
147
260
|
}
|
|
148
261
|
|
|
149
|
-
|
|
150
|
-
const
|
|
262
|
+
/** An error that says the path is not there (an empty profile), as opposed to being there and unreadable. */
|
|
263
|
+
const isAbsent = (error) => error?.code === "ENOENT";
|
|
151
264
|
|
|
152
|
-
/** The
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const subdirs = (dir) => {
|
|
156
|
-
try { return fsApi.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !NOT_PROFILES.has(e.name)).map((e) => e.name); } catch { return []; }
|
|
157
|
-
};
|
|
158
|
-
const has = (dir) => { try { return fsApi.statSync(dir).isDirectory(); } catch { return false; } };
|
|
159
|
-
for (const first of subdirs(targetDir)) {
|
|
160
|
-
const direct = path.join(targetDir, first, ".fingerprint");
|
|
161
|
-
if (has(direct)) { found.push(direct); continue; }
|
|
162
|
-
for (const second of subdirs(path.join(targetDir, first))) {
|
|
163
|
-
const nested = path.join(targetDir, first, second, ".fingerprint");
|
|
164
|
-
if (has(nested)) found.push(nested);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return found;
|
|
265
|
+
/** The fingerprint directory's entries: `{ names }`, `{ names: [] }` when it is absent, or `{ error }` when it exists and cannot be read (a file, permission, I/O: never read as empty). */
|
|
266
|
+
function readFingerprintDirectory(dir, fsApi) {
|
|
267
|
+
try { return { names: fsApi.readdirSync(dir) }; } catch (error) { return isAbsent(error) ? { names: [] } : { error: `${dir}: ${error?.code ?? error?.message ?? "unreadable"}` }; }
|
|
168
268
|
}
|
|
169
269
|
|
|
170
270
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
271
|
+
* The `build.target` triples `.cargo/config.toml` / `.cargo/config` name for a compile run from `cwd` (the process's own directory upward, then `$CARGO_HOME`): the compile's output then
|
|
272
|
+
* sits under `<target>/<triple>/<profile>`, so the selected scope must name it as `--target` would. A simple reader of the `[build]` table's `target` string or string list.
|
|
173
273
|
*/
|
|
174
|
-
export function
|
|
175
|
-
const
|
|
176
|
-
let
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
for (const file of fsApi.readdirSync(unitDir)) {
|
|
192
|
-
visited += 1;
|
|
193
|
-
if (visited > cap) return { units, capped: true };
|
|
194
|
-
try { newest = Math.max(newest, fsApi.statSync(path.join(unitDir, file)).mtimeMs); } catch { /* vanished: ignore */ }
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
} catch { /* vanished: counted with the time it had */ }
|
|
198
|
-
units.set(unitDir, newest);
|
|
199
|
-
}
|
|
274
|
+
export function configuredBuildTargets(cwd, { env = {}, fsApi = fs } = {}) {
|
|
275
|
+
const files = [];
|
|
276
|
+
if (typeof cwd === "string" && cwd) for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) { files.push(path.join(dir, ".cargo", "config.toml"), path.join(dir, ".cargo", "config")); if (path.dirname(dir) === dir) break; }
|
|
277
|
+
const home = Object.entries(env ?? {}).find(([key]) => key.toUpperCase() === "CARGO_HOME")?.[1];
|
|
278
|
+
if (typeof home === "string" && home) files.push(path.join(home, "config.toml"), path.join(home, "config"));
|
|
279
|
+
for (const file of files) {
|
|
280
|
+
let text;
|
|
281
|
+
try { text = fsApi.readFileSync(file, "utf8"); } catch { continue; }
|
|
282
|
+
let inBuild = false;
|
|
283
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
284
|
+
const line = raw.replace(/#.*$/, "").trim();
|
|
285
|
+
const table = /^\[\s*([^\]]+?)\s*\]$/.exec(line);
|
|
286
|
+
if (table) { inBuild = table[1] === "build"; continue; }
|
|
287
|
+
const dotted = /^build\.target\s*=\s*(.+)$/.exec(line);
|
|
288
|
+
const plain = inBuild ? /^target\s*=\s*(.+)$/.exec(line) : null;
|
|
289
|
+
const value = (dotted ?? plain)?.[1];
|
|
290
|
+
if (value !== undefined) return [...value.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
|
|
200
291
|
}
|
|
201
292
|
}
|
|
202
|
-
return
|
|
293
|
+
return [];
|
|
203
294
|
}
|
|
204
295
|
|
|
205
296
|
/**
|
|
206
|
-
* One census for one stage of a direct compile: `start()` before the scans, `wrapSpawn(spawn)` around the scan runner's spawner
|
|
207
|
-
*
|
|
297
|
+
* One census for one stage of a direct compile: `start()` before the scans, `wrapSpawn(spawn)` around the scan runner's spawner (it selects each compiling cargo process's scope and
|
|
298
|
+
* turns the fingerprint log on for it only), `finish()` after them. `start({ argv, triples, env })` selects a scope itself (a caller that knows the command).
|
|
299
|
+
*
|
|
300
|
+
* The scope is what the compiling processes SELECT (cargoProfileDirectory, cargoTargetTriples), not every profile directory of the target: each selected directory's baseline is read
|
|
301
|
+
* before the first process that selects it, and `since` is the census start. A directory that exists and cannot be read, at the baseline or in the walk, makes the census
|
|
302
|
+
* unavailable (`fingerprint-census-read-failed`); with no compiling process there is no scope and the census is unavailable (`fingerprint-census-no-compiling-process`).
|
|
208
303
|
*/
|
|
209
304
|
export function createFreshnessCensus({ targetDirs, now = Date.now, fsApi = fs, cap = CENSUS_ENTRY_CAP } = {}) {
|
|
210
305
|
const dirs = [...new Set(Object.values(targetDirs ?? {}).filter((d) => typeof d === "string" && d))];
|
|
211
306
|
const log = new FingerprintLog({ strip: false });
|
|
212
|
-
let since = null; let
|
|
307
|
+
let since = null; let processes = 0; let outputCut = false;
|
|
308
|
+
const baselines = new Map(); // fingerprint directory -> { count } | { error }
|
|
309
|
+
let unitsAtStart = 0; let capped = false; let readError = null; let selected = false;
|
|
310
|
+
|
|
311
|
+
const select = ({ argv = [], triples = null, env = {}, cwd = null } = {}) => {
|
|
312
|
+
const named = triples ?? [...cargoTargetTriples(argv, env), ...(cargoTargetTriples(argv, env).length === 0 ? configuredBuildTargets(cwd, { env, fsApi }) : [])];
|
|
313
|
+
for (const targetDir of dirs) {
|
|
314
|
+
for (const dir of selectedFingerprintDirectories(targetDir, { argv, triples: [...new Set(named)] })) {
|
|
315
|
+
selected = true;
|
|
316
|
+
if (baselines.has(dir)) continue;
|
|
317
|
+
const read = readFingerprintDirectory(dir, fsApi);
|
|
318
|
+
if (read.error !== undefined) { baselines.set(dir, { error: read.error }); readError ??= read.error; continue; }
|
|
319
|
+
baselines.set(dir, { count: read.names.length });
|
|
320
|
+
unitsAtStart += read.names.length;
|
|
321
|
+
if (unitsAtStart > cap) capped = true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** Walks the selected directories: the units' newest file times, or why the walk cannot be trusted. */
|
|
327
|
+
const walk = () => {
|
|
328
|
+
const units = new Map();
|
|
329
|
+
let visited = 0;
|
|
330
|
+
for (const dir of baselines.keys()) {
|
|
331
|
+
const read = readFingerprintDirectory(dir, fsApi);
|
|
332
|
+
if (read.error !== undefined) return { reason: "fingerprint-census-read-failed" };
|
|
333
|
+
for (const name of read.names) {
|
|
334
|
+
visited += 1;
|
|
335
|
+
if (visited > cap) return { reason: "fingerprint-census-entry-cap-exceeded" };
|
|
336
|
+
const unitDir = path.join(dir, name);
|
|
337
|
+
let newest = 0;
|
|
338
|
+
let files = [];
|
|
339
|
+
try { files = fsApi.readdirSync(unitDir); } catch (error) {
|
|
340
|
+
// A unit that vanished meanwhile, or is a plain file, holds no files; any other failure means its freshness was never read.
|
|
341
|
+
if (!isAbsent(error) && error?.code !== "ENOTDIR") return { reason: "fingerprint-census-read-failed" };
|
|
342
|
+
}
|
|
343
|
+
for (const file of files) {
|
|
344
|
+
visited += 1;
|
|
345
|
+
if (visited > cap) return { reason: "fingerprint-census-entry-cap-exceeded" };
|
|
346
|
+
try { newest = Math.max(newest, fsApi.statSync(path.join(unitDir, file)).mtimeMs); } catch { /* vanished: it holds no time */ }
|
|
347
|
+
}
|
|
348
|
+
units.set(unitDir, newest);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { units };
|
|
352
|
+
};
|
|
353
|
+
|
|
213
354
|
return {
|
|
214
|
-
start() { since = now();
|
|
355
|
+
start(selection) { since = now(); if (selection) select(selection); },
|
|
356
|
+
select,
|
|
357
|
+
/** What the selection reads at the start: the directories, the units counted there, whether the entry cap was passed and the first read error. */
|
|
358
|
+
scope() { return { directories: [...baselines.keys()], unitsAtStart, capped, readError }; },
|
|
215
359
|
wrapSpawn(spawn) {
|
|
216
360
|
return async (command, args, options = {}) => {
|
|
217
361
|
if (!compilesWorkspace(command, args)) return spawn(command, args, options);
|
|
218
362
|
const env = options.env ?? process.env;
|
|
363
|
+
if (since !== null) select({ argv: Array.isArray(args) ? args : [], env, cwd: options.cwd ?? process.cwd() });
|
|
219
364
|
// A caller's own CARGO_LOG stands and stays visible; the gate's own is kept out of the output.
|
|
220
365
|
const ours = !Object.keys(env).some((k) => k.toUpperCase() === "CARGO_LOG");
|
|
221
366
|
const result = await spawn(command, args, ours ? { ...options, env: { ...env, CARGO_LOG: FINGERPRINT_LOG_FILTER } } : options);
|
|
@@ -231,13 +376,16 @@ export function createFreshnessCensus({ targetDirs, now = Date.now, fsApi = fs,
|
|
|
231
376
|
finish() {
|
|
232
377
|
const base = { source: FRESHNESS_SOURCE, cargoProcesses: processes, dirtyUnits: log.dirtyUnits, truncated: log.truncated, ...(outputCut ? { outputCut: true } : {}) };
|
|
233
378
|
const unavailable = (reason) => ({ unitsAtStart: 0, fresh: 0, notFresh: 0, invalidationReasons: log.reasons, available: false, reason, ...base });
|
|
234
|
-
if (since === null
|
|
235
|
-
|
|
236
|
-
if (
|
|
379
|
+
if (since === null) return unavailable("fingerprint-census-not-started");
|
|
380
|
+
if (!selected) return unavailable("fingerprint-census-no-compiling-process");
|
|
381
|
+
if (capped) return unavailable("fingerprint-census-entry-cap-exceeded");
|
|
382
|
+
if (readError !== null) return unavailable("fingerprint-census-read-failed");
|
|
383
|
+
const end = walk();
|
|
384
|
+
if (end.reason) return unavailable(end.reason);
|
|
237
385
|
if (end.units.size === 0) return unavailable("no-fingerprint-units-found");
|
|
238
386
|
let notFresh = 0;
|
|
239
387
|
for (const newest of end.units.values()) if (newest >= since) notFresh += 1;
|
|
240
|
-
return { unitsAtStart
|
|
388
|
+
return { unitsAtStart, fresh: end.units.size - notFresh, notFresh, invalidationReasons: log.reasons, available: true, ...base };
|
|
241
389
|
},
|
|
242
390
|
};
|
|
243
391
|
}
|
|
@@ -250,7 +398,7 @@ export function validateFreshness(freshness) {
|
|
|
250
398
|
if (!freshness || typeof freshness !== "object" || Array.isArray(freshness)) return ["freshness is absent"];
|
|
251
399
|
for (const key of ["unitsAtStart", "fresh", "notFresh"]) if (!isCount(freshness[key])) problems.push(`freshness.${key} is not a count`);
|
|
252
400
|
if (!Array.isArray(freshness.invalidationReasons)) problems.push("freshness.invalidationReasons is not a list");
|
|
253
|
-
else freshness.invalidationReasons.forEach((r, i) => { if (!r || typeof r.unit !== "string" || typeof r.kind !== "string" || typeof r.reason !== "string") problems.push(`freshness.invalidationReasons[${i}] has no unit, kind and reason`); });
|
|
401
|
+
else freshness.invalidationReasons.forEach((r, i) => { if (!r || typeof r.unit !== "string" || typeof r.kind !== "string" || typeof r.reason !== "string") problems.push(`freshness.invalidationReasons[${i}] has no unit, kind and reason`); else if (r.cause !== undefined && typeof r.cause !== "string") problems.push(`freshness.invalidationReasons[${i}].cause is not a string`); });
|
|
254
402
|
if (freshness.available !== undefined && typeof freshness.available !== "boolean") problems.push("freshness.available is not a boolean");
|
|
255
403
|
if (freshness.available === false && typeof freshness.reason !== "string") problems.push("an unavailable census names no reason");
|
|
256
404
|
return problems;
|
package/gate-executor.mjs
CHANGED
|
@@ -2,27 +2,45 @@
|
|
|
2
2
|
// broker - `rustright` is on PATH, or the first `cargo` is the broker's managed shim: every cargo call goes through the broker.
|
|
3
3
|
// refuse - a broker-managed machine (Mac/Dell markers, broker socket, managed agent-bin) whose broker cannot be reached from here:
|
|
4
4
|
// the gate never compiles around it.
|
|
5
|
-
// direct - every Linux host (WSL2, VM, cloud, CI, Linux hosted on the Dell)
|
|
6
|
-
// runs directly, through the same gates and stages.
|
|
5
|
+
// direct - every Linux host (WSL2, VM, cloud, CI, Linux hosted on the Dell) that has not declared a broker route, and any unmanaged Mac or
|
|
6
|
+
// Windows host: cargo runs directly, through the same gates and stages.
|
|
7
|
+
// Linux optional broker (Adrian, 2026-10-31: Linux normally needs no broker, but a broker is allowed there): a Linux host that EXPLICITLY declares
|
|
8
|
+
// RIGHTKIT_LINUX_BROKER_CLIENT + RIGHTKIT_LINUX_BROKER_SOCKET (managed-cargo.mjs resolveLinuxBrokerRoute) gets kind "broker" with route
|
|
9
|
+
// "linux-broker" when the client, endpoint and optional pinned digest validate, and kind "refuse" when they do not. A declared Linux broker never
|
|
10
|
+
// falls back to direct cargo; a `rustright` on PATH, a shim directory or a socket named in another variable never declares it.
|
|
7
11
|
import { brokerManagedHost, managedCargoShimOnPath } from "./cargo-contract.mjs";
|
|
8
|
-
import { directCargoPath, directPlatform } from "./managed-cargo.mjs";
|
|
12
|
+
import { LINUX_BROKER_ROUTE, directCargoPath, directPlatform, resolveLinuxBrokerRoute } from "./managed-cargo.mjs";
|
|
9
13
|
|
|
10
|
-
export { directCargoPath };
|
|
14
|
+
export { directCargoPath, LINUX_BROKER_ROUTE };
|
|
11
15
|
|
|
12
16
|
export const EXECUTOR_KINDS = Object.freeze(["broker", "refuse", "direct"]);
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
|
-
* @param {{ env?: object, platform?: string, exists?: (p: string) => boolean, which: (cmd: string) => string[], readFile: (p: string) => string }} o
|
|
16
|
-
* @returns {{ kind: "broker" | "refuse" | "direct", cargo: string[] | null, problem?: string, env?: { PATH
|
|
19
|
+
* @param {{ forceDirect?: boolean, env?: object, platform?: string, exists?: (p: string) => boolean, which: (cmd: string) => string[], readFile: (p: string) => string, fsOps?: object }} o
|
|
20
|
+
* @returns {{ kind: "broker" | "refuse" | "direct", cargo: string[] | null, problem?: string, code?: string, route?: "linux-broker", client?: string, socket?: string, clientSha256?: string, env?: { PATH?: string, RUSTRIGHT_BUILD_BROKER_SOCKET?: string }, note?: string }}
|
|
17
21
|
*/
|
|
18
|
-
export function resolveExecutor({ env = process.env, platform = process.platform, exists, which, readFile }) {
|
|
22
|
+
export function resolveExecutor({ env = process.env, platform = process.platform, exists, which, readFile, fsOps, forceDirect = false }) {
|
|
19
23
|
// Every Linux (and any other non-Mac, non-Windows) execution host runs direct, whatever is on PATH or in the environment: WSL2, a VM,
|
|
20
|
-
// a cloud or CI runner, or Linux hosted on the Dell (GATE-CONTRACT §1 "One entry"; joint plan step 4)
|
|
21
|
-
// hosts use the broker.
|
|
24
|
+
// a cloud or CI runner, or Linux hosted on the Dell (GATE-CONTRACT §1 "One entry"; joint plan step 4), unless the host explicitly declared
|
|
25
|
+
// the optional Linux broker route (below). Adrian's Mac and Windows hosts use the broker as before.
|
|
22
26
|
// `cargo` there must be a real toolchain: RustRight shim directories are taken off the PATH the gate hands its children (`env`).
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
// `forceDirect` is the one documented exception (broker-exception.mjs, validated by the gate before it is passed): a Mac or Dell run
|
|
28
|
+
// outside the broker, by Adrian's recorded approval. It takes the direct route with the same shim scrub, never the Linux broker route.
|
|
29
|
+
if (forceDirect || directPlatform(platform)) {
|
|
30
|
+
const linux = forceDirect ? { declared: false } : resolveLinuxBrokerRoute({ env, platform, fsOps });
|
|
31
|
+
if (linux.declared) {
|
|
32
|
+
// One typed choice for the gate, managed-cargo, the carried adapters and every child (they read it again from the same environment): the
|
|
33
|
+
// validated client and endpoint, never a PATH lookup. `env` is what the gate hands its children: the endpoint the client must talk to.
|
|
34
|
+
if (!linux.ok) return { kind: "refuse", cargo: null, route: LINUX_BROKER_ROUTE, code: linux.code, problem: linux.problem };
|
|
35
|
+
return {
|
|
36
|
+
kind: "broker", route: LINUX_BROKER_ROUTE, cargo: [linux.client, "cargo"], client: linux.client, socket: linux.socket, clientSha256: linux.clientSha256,
|
|
37
|
+
env: { RUSTRIGHT_BUILD_BROKER_SOCKET: linux.socket },
|
|
38
|
+
note: `declared Linux broker route: client ${linux.client} (sha256 ${linux.clientSha256.slice(0, 12)}), endpoint ${linux.socket}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const pathKey = platform === "win32" ? Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH" : "PATH";
|
|
42
|
+
const direct = directCargoPath({ env: { ...env, PATH: env[pathKey] }, exists, platform });
|
|
43
|
+
return { kind: "direct", cargo: ["cargo"], ...(direct.dropped.length ? { env: { [pathKey]: direct.path }, note: `RustRight shims taken off PATH for direct cargo: ${direct.dropped.join(", ")}` } : {}), ...(direct.cargo ? {} : { note: `no direct cargo on PATH${direct.dropped.length ? ` (RustRight shims skipped: ${direct.dropped.join(", ")})` : ""}; cargo scans fail as a missing tool` }) };
|
|
26
44
|
}
|
|
27
45
|
if (which("rustright").length) return { kind: "broker", cargo: ["rustright", "cargo"] };
|
|
28
46
|
const first = which("cargo")[0];
|
package/gate-stages.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { createHash, randomBytes } from "node:crypto";
|
|
12
12
|
import fs from "node:fs";
|
|
13
13
|
import path from "node:path";
|
|
14
|
+
import { writeFileAtomic } from "./atomic-write.mjs";
|
|
15
|
+
import { platformDirOf } from "./platform-dir.mjs";
|
|
14
16
|
|
|
15
17
|
export const STAGES = Object.freeze(["check", "compile", "package", "qualify", "release"]);
|
|
16
18
|
export const RECEIPT_SCHEMA = "rightkit.gate-receipt/1";
|
|
@@ -25,8 +27,11 @@ const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
|
25
27
|
* `--to test` included, builds a commit; uncommitted changes never enter a build). One row per stage name the gate dispatches:
|
|
26
28
|
* the STAGES, `dev` and `test`, `stage` (the hand-off to the publisher) and each STAGES entry again as a RightKit unit stage
|
|
27
29
|
* (`--to <stage> --unit <selector>`). `compiles` and `packages` say whether the stage starts a compiler or seals bytes.
|
|
28
|
-
* source "snapshot": the gate-owned snapshot of the selected commit (
|
|
29
|
-
*
|
|
30
|
+
* source "snapshot": the gate-owned snapshot of the selected commit (the checkout is never a build source on any host: rightkit#1301 deleted
|
|
31
|
+
* the checkout route, so no row carries an interim checkout rule). `implemented: true` says the executable route exists in code and runs;
|
|
32
|
+
* `witnessed: false` with `pending` says which host acceptance (native fixture witness on the Mac and the Dell) has not yet been proved. The two
|
|
33
|
+
* are separate facts (GB-86): an implemented route may run for its witness, and a route whose host acceptance is unproved is never presented as
|
|
34
|
+
* ready for apps (`appFacingReady`). A stage the route planner cannot run fails at planning with a named capability, not through this table.
|
|
30
35
|
* source "sealed" or "publisher", with `basis`: the stage compiles nothing from source (it acts on bytes an earlier stage
|
|
31
36
|
* sealed, or runs only in the trusted publisher on hosted CI with its normal checkout, docs/GATE-CONTRACT.md).
|
|
32
37
|
* source "checkout", with `gap`: a known breach, listed so it cannot grow; stageSourceProblems ignores it, stageSourceGaps names it.
|
|
@@ -35,24 +40,35 @@ const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
|
35
40
|
*/
|
|
36
41
|
export const STAGE_SOURCES = Object.freeze({
|
|
37
42
|
check: { compiles: false, packages: false, source: "snapshot" },
|
|
38
|
-
compile: { compiles: true, packages: false, source: "snapshot"
|
|
39
|
-
//
|
|
40
|
-
package: { compiles: true, packages: true, source: "snapshot",
|
|
43
|
+
compile: { compiles: true, packages: false, source: "snapshot" },
|
|
44
|
+
// Implemented in code (row 7.04: build-release.mjs, release.mjs, cli/right-release.mjs, release-source-identity.mjs): right-release builds and seals the selected commit from the snapshot. `witnessed` becomes true (and `pending` goes) when door-package.test.mjs passes on Linux and the native fixture witness passes on the Mac and the Dell (dev broker).
|
|
45
|
+
package: { compiles: true, packages: true, source: "snapshot", implemented: true, witnessed: false, pending: "route implemented in code (rows 7.04 and 7.04c: release-artifact-path.mjs binds each Cargo manifest's target, one resolver reads every declared artifact path); awaiting 7.04c green on L, M and D: the dev-broker rows need the snapshot carrier (rows 7.20 to 7.24) and RustRight's item-4 dev pins" },
|
|
41
46
|
// Narrowed by decision 2 (7.06 correction): qualification rebuilds nothing, but its source reads (config, QA plan, test code) go through the split.
|
|
42
|
-
qualify: { compiles: false, packages: false, source: "snapshot",
|
|
47
|
+
qualify: { compiles: false, packages: false, source: "snapshot", implemented: true, witnessed: false, pending: "qa --stage installed takes its identity from the split (release-qa-command.mjs, row 7.04); awaiting its tests on every host" },
|
|
43
48
|
release: { compiles: false, packages: false, source: "publisher", basis: "--to release runs only in the trusted publisher on hosted CI with its normal checkout (docs/GATE-CONTRACT.md), and only uploads sealed bytes" },
|
|
44
|
-
dev: { compiles: true, packages: false, source: "snapshot"
|
|
45
|
-
test: { compiles: true, packages: false, source: "snapshot"
|
|
49
|
+
dev: { compiles: true, packages: false, source: "snapshot" },
|
|
50
|
+
test: { compiles: true, packages: false, source: "snapshot" },
|
|
46
51
|
stage: { compiles: false, packages: false, source: "sealed", basis: "stage packs the sealed directory and its receipt into the publisher's draft release; it compiles nothing" },
|
|
47
|
-
// Row 7.06a: a unit stage builds the selected commit's snapshot (
|
|
48
|
-
|
|
49
|
-
"unit:
|
|
50
|
-
"unit:
|
|
51
|
-
"unit:
|
|
52
|
-
"unit:qualify": { compiles: false, packages: false, source: "snapshot", interim: "Mac and Dell: clean HEAD of the checkout until the cutover" },
|
|
52
|
+
// Row 7.06a: a unit stage builds the selected commit's snapshot on every host (runUnitSnapshot); hosted CI keeps its runner checkout, which is the commit.
|
|
53
|
+
"unit:check": { compiles: false, packages: false, source: "snapshot" },
|
|
54
|
+
"unit:compile": { compiles: true, packages: false, source: "snapshot" },
|
|
55
|
+
"unit:package": { compiles: true, packages: true, source: "snapshot" },
|
|
56
|
+
"unit:qualify": { compiles: false, packages: false, source: "snapshot" },
|
|
53
57
|
"unit:release": { compiles: false, packages: false, source: "publisher", basis: "a unit release is dispatched to the trusted publisher (releaseUnit); it compiles nothing here" },
|
|
54
58
|
});
|
|
55
59
|
|
|
60
|
+
/**
|
|
61
|
+
* What a row says about readiness: `implemented` (the executable route exists), `witnessed` (host acceptance proved; absent means nothing is pending for the
|
|
62
|
+
* row) and `pending` (what remains). `appFacingReady` is true only for an implemented route with no pending host acceptance.
|
|
63
|
+
*/
|
|
64
|
+
export function stageReadiness(stage, table = STAGE_SOURCES) {
|
|
65
|
+
const row = table[stage];
|
|
66
|
+
if (!row) return null;
|
|
67
|
+
const implemented = row.source === "snapshot" ? row.implemented !== false : true;
|
|
68
|
+
const pending = typeof row.pending === "string" && row.pending ? row.pending : null;
|
|
69
|
+
return { stage, implemented, witnessed: pending === null && row.witnessed !== false, pending, appFacingReady: implemented && pending === null && row.witnessed !== false };
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
/** Rows of `table` that compile or package without a snapshot source, a stated exclusion basis or a registered gap. */
|
|
57
73
|
export function stageSourceProblems(table = STAGE_SOURCES) {
|
|
58
74
|
const out = [];
|
|
@@ -148,7 +164,7 @@ export function readReceipt({ commonDir, commit, app = ".", readFile = (f) => fs
|
|
|
148
164
|
return null;
|
|
149
165
|
}
|
|
150
166
|
|
|
151
|
-
export function writeReceipt({ commonDir, receipt, write =
|
|
167
|
+
export function writeReceipt({ commonDir, receipt, write = (file, data) => writeFileAtomic(file, data), mkdir = fs.mkdirSync }) {
|
|
152
168
|
const dir = path.join(gateStateDir(commonDir), "receipts");
|
|
153
169
|
mkdir(dir, { recursive: true });
|
|
154
170
|
write(path.join(dir, `${receiptKey(receipt.commit, receipt.app ?? ".")}.json`), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
@@ -173,13 +189,74 @@ export function sameSource(a, b) {
|
|
|
173
189
|
return Boolean(a && b) && a.tree === b.tree && a.gate === b.gate && JSON.stringify(a.locks) === JSON.stringify(b.locks);
|
|
174
190
|
}
|
|
175
191
|
|
|
192
|
+
/** Entries a compile-output census walks before it gives up (and the compile is then run again, warm, rather than trusted). */
|
|
193
|
+
export const OUTPUT_CENSUS_LIMIT = 250000;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What the compile stage produced, as the door can prove it later (GB-13, GATE E6/E7/E16): for each output directory the broker's
|
|
197
|
+
* receipt (or the direct host's resolution) named, every file's relative path and size, digested together. `truncated` means the walk
|
|
198
|
+
* hit `limit`; such a census never proves anything. A missing directory is recorded as missing, never as empty.
|
|
199
|
+
*/
|
|
200
|
+
export function outputCensus(dirs = [], { limit = OUTPUT_CENSUS_LIMIT, readdir = (d) => fs.readdirSync(d, { withFileTypes: true }), stat = (f) => fs.statSync(f) } = {}) {
|
|
201
|
+
const unique = [...new Set(dirs.filter((d) => typeof d === "string" && d))].sort();
|
|
202
|
+
const lines = []; let truncated = false; const missing = [];
|
|
203
|
+
for (const dir of unique) {
|
|
204
|
+
const stack = [""];
|
|
205
|
+
let exists = true;
|
|
206
|
+
while (stack.length && !truncated) {
|
|
207
|
+
const rel = stack.pop();
|
|
208
|
+
let entries;
|
|
209
|
+
try { entries = readdir(path.join(dir, rel)); } catch { if (rel === "") exists = false; continue; }
|
|
210
|
+
for (const e of entries) {
|
|
211
|
+
const next = rel ? `${rel}/${e.name}` : e.name;
|
|
212
|
+
if (e.isDirectory()) { stack.push(next); continue; }
|
|
213
|
+
let size = -1; try { size = stat(path.join(dir, next)).size; } catch { /* vanished while walking: recorded as -1 */ }
|
|
214
|
+
lines.push(`${dir}\0${next}\0${size}`);
|
|
215
|
+
if (lines.length > limit) { truncated = true; break; }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!exists) missing.push(dir);
|
|
219
|
+
}
|
|
220
|
+
lines.sort();
|
|
221
|
+
return { dirs: unique, entries: lines.length, truncated, missing, digest: sha256(lines.join("\n")) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The environment a Cargo compile reads that picks its profile, target or flags: part of what a compile result is valid for. */
|
|
225
|
+
const COMPILE_ENV = /^(CARGO_(?!HOME$|TARGET_DIR$|BUILD_TARGET_DIR$|REGISTRIES_|NET_|HTTP_|TERM_)|RUSTFLAGS$|RUSTDOCFLAGS$|RUSTC(_|$)|RUSTUP_TOOLCHAIN$)/;
|
|
226
|
+
export function compileKeyOf(env = {}) {
|
|
227
|
+
const picked = Object.entries(env).filter(([k, v]) => COMPILE_ENV.test(k) && v !== undefined).sort(([a], [b]) => a.localeCompare(b));
|
|
228
|
+
return sha256(JSON.stringify(picked));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** What a passed compile stage records about its outputs: the directories, their census and the key of the compile environment. */
|
|
232
|
+
export function compileOutputsRecord({ dirs = [], env = {}, census = outputCensus }) {
|
|
233
|
+
const c = census(dirs);
|
|
234
|
+
return { dirs: c.dirs, entries: c.entries, digest: c.digest, truncated: c.truncated, key: compileKeyOf(env) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Whether a recorded compile still stands for the outputs on disk now: null when it does, else the reason it does not. A record with no
|
|
239
|
+
* `outputs` (a receipt from before this binding) proves nothing and does not stand.
|
|
240
|
+
*/
|
|
241
|
+
export function compileReuseProblem(recorded, { env = {}, census = outputCensus } = {}) {
|
|
242
|
+
const out = recorded?.outputs;
|
|
243
|
+
if (!out || typeof out !== "object" || !Array.isArray(out.dirs) || typeof out.digest !== "string") return "the compile result records no outputs";
|
|
244
|
+
if (out.truncated) return "the recorded output census was incomplete";
|
|
245
|
+
if (out.key !== compileKeyOf(env)) return "the compile environment (profile, target or flags) differs from the recorded one";
|
|
246
|
+
const now = census(out.dirs);
|
|
247
|
+
if (now.missing.length) return `output directory ${now.missing[0]} is gone`;
|
|
248
|
+
if (now.truncated) return "the output census is too large to prove";
|
|
249
|
+
if (now.digest !== out.digest || now.entries !== out.entries) return "the compile outputs on disk differ from the recorded census (a required output was removed, replaced or added)";
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
176
253
|
/** The sealed release directory build-release wrote for `commit` on `platform`: { id, manifest, digest } or null. */
|
|
177
254
|
export function sealedFor({ root, commit, platform, readdir = fs.readdirSync, readFile = (f) => fs.readFileSync(f) }) {
|
|
178
255
|
const base = path.join(root, ".right-release", "sealed");
|
|
179
256
|
let ids = [];
|
|
180
257
|
try { ids = readdir(base).filter((id) => id.endsWith(`-${commit.slice(0, 8)}`)); } catch { return null; }
|
|
181
258
|
for (const id of ids.sort()) {
|
|
182
|
-
const file = path.join(base, id, platform, "release-manifest.json");
|
|
259
|
+
const file = path.join(base, id, platformDirOf(platform), "release-manifest.json");
|
|
183
260
|
try { const bytes = readFile(file); return { id, manifest: file, digest: sha256(bytes) }; } catch { /* not this platform */ }
|
|
184
261
|
}
|
|
185
262
|
return null;
|
package/github-release.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { verifySealedRelease } from "./release-state.mjs";
|
|
|
11
11
|
import { admitInstalledQa } from "./release-qa-audit.mjs";
|
|
12
12
|
import { addonManifestSha256, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
|
|
13
13
|
import { RIGHTRELEASE_MANIFEST_SIGNER, verifyManifestCatalogSignature } from "./sign-release-manifest.mjs";
|
|
14
|
+
import { platformDirOf } from "./platform-dir.mjs";
|
|
14
15
|
|
|
15
16
|
const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
16
17
|
const RELEASE_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
|
|
@@ -21,7 +22,7 @@ export function prepareGitHubRelease({ repoRoot, releaseId, platform, repo, stat
|
|
|
21
22
|
if (!RELEASE_RE.test(releaseId)) throw new Error(`invalid release id: ${releaseId}`);
|
|
22
23
|
if (platform !== "mac" && platform !== "win") throw new Error("platform must be mac or win");
|
|
23
24
|
if (!REPO_RE.test(repo)) throw new Error(`invalid GitHub repository: ${repo}`);
|
|
24
|
-
const platformDir = platform
|
|
25
|
+
const platformDir = platformDirOf(platform);
|
|
25
26
|
const sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir));
|
|
26
27
|
if (sealed.manifest.platform !== platform) throw new Error("sealed release platform mismatch");
|
|
27
28
|
const installers = sealed.manifest.files.filter((file) => file.role === "installer");
|
package/github-upload-gate.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
|
|
|
18
18
|
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
19
19
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
20
20
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
21
|
+
import { platformDirOf } from "./platform-dir.mjs";
|
|
21
22
|
|
|
22
23
|
/** Throws unless the operator named a tier. There is no default and no inference from the version or the notes. */
|
|
23
24
|
export function assertUploadTier(tier) {
|
|
@@ -63,7 +64,7 @@ export async function authorizeGitHubUpload({
|
|
|
63
64
|
status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
|
|
64
65
|
commandId: command,
|
|
65
66
|
});
|
|
66
|
-
const platformDir = platform
|
|
67
|
+
const platformDir = platformDirOf(platform);
|
|
67
68
|
const sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir));
|
|
68
69
|
if (sealed.manifest.commit !== candidateCommit) throw new Error("sealed release was built from a different commit than the current checkout");
|
|
69
70
|
|