@rightkit/release 0.6.10 → 0.6.12
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 -5
- package/admission-files.mjs +3 -0
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +13 -9
- package/bin/rightkit-gate.mjs +772 -355
- package/broker-pins.mjs +152 -0
- package/build-release.mjs +80 -27
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +98 -26
- package/cli/right-release.mjs +28 -4
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +196 -21
- package/development.mjs +13 -4
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +10 -0
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-stages.mjs +76 -10
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/inventory-check.mjs +11 -8
- package/ios-upload.mjs +20 -15
- package/local-publish.mjs +77 -41
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/mirror-root-artifact.mjs +18 -5
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +35 -6
- package/package.json +1 -1
- package/pipeline-admission.mjs +4 -4
- package/preflight.mjs +27 -4
- package/publish-cargo.mjs +4 -3
- package/recipe-build.mjs +38 -14
- package/release-artifact-path.mjs +341 -0
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +44 -17
- package/rightkit-versions.json +63 -42
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +12 -6
- package/snapshot-request.mjs +207 -5
- package/snapshot.mjs +123 -10
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +2 -2
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +100 -20
- package/swap-helper-bundle.mjs +5 -2
- package/unit-gate.mjs +45 -3
- package/web-build.mjs +6 -4
- package/win-package-recipe.mjs +20 -7
package/broker-pins.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The gate's own record of the broker it trusts on this host (Sage decision A, item 7 row 7.21; AR 256-258: pins come from the
|
|
2
|
+
// consumer's own enrollment, "never from the receipt, request or project").
|
|
3
|
+
//
|
|
4
|
+
// record `<managed data root>/pins/broker-<platform>.json`, `{schema, platform, host, keyId, publicKey, brokerDigest, enrolledAt}`,
|
|
5
|
+
// outside every repository and snapshot, written only by `enrollBrokerPins` (an explicit operation; a second enrollment
|
|
6
|
+
// for the same platform is a rotation and says so).
|
|
7
|
+
// fallback RightKit's own checkout only: `ci/rk-pipeline.json` `attestation.<platform>`, read from the revision of the RUNNING gate
|
|
8
|
+
// (`git show <gate repository HEAD>:ci/rk-pipeline.json`), never from the selected commit, the snapshot or the working tree.
|
|
9
|
+
//
|
|
10
|
+
// Never read here: the broker's control root, its runtime manifest, `attestation/key.json` (it holds the SECRET key), an environment
|
|
11
|
+
// variable, a flag, the snapshot or a receipt. A missing record in any repository but RightKit refuses the snapshot compile; nothing
|
|
12
|
+
// is enrolled automatically.
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { spawnSync } from "./background-process.mjs";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
export const PINS_SCHEMA = "rightkit.broker-pins/1";
|
|
20
|
+
export const PINS_DIR = "pins";
|
|
21
|
+
export const PIPELINE_FILE = "ci/rk-pipeline.json";
|
|
22
|
+
export const PIPELINE_SCHEMA = "rk-pipeline.v1";
|
|
23
|
+
|
|
24
|
+
export class BrokerPinsError extends Error {
|
|
25
|
+
constructor(code, message) { super(message); this.name = "BrokerPinsError"; this.code = code; }
|
|
26
|
+
}
|
|
27
|
+
const fail = (code, message) => { throw new BrokerPinsError(code, message); };
|
|
28
|
+
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
|
29
|
+
const HEX = /^(?:[0-9a-f]{2})+$/;
|
|
30
|
+
|
|
31
|
+
/** The pin record of one platform under the managed data root. */
|
|
32
|
+
export const pinsFile = ({ dataRoot, platform = process.platform }) => {
|
|
33
|
+
if (!dataRoot || !path.isAbsolute(dataRoot)) fail("RK_BROKER_PINS_ROOT", "the managed data root must be an absolute path");
|
|
34
|
+
if (!["darwin", "win32"].includes(platform)) fail("RK_BROKER_PINS_PLATFORM", `${platform} is not a broker host (darwin and win32 only)`);
|
|
35
|
+
return path.join(dataRoot, PINS_DIR, `broker-${platform}.json`);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** `{host, keyId, publicKey, brokerDigest}` or a throw naming what is wrong. The key must be an uncompressed P-256 point whose sha256 is the key id. */
|
|
39
|
+
export function validatePins(pins, label = "the broker pins") {
|
|
40
|
+
const bad = (why) => fail("RK_BROKER_PINS_INVALID", `${label}: ${why}`);
|
|
41
|
+
if (pins === null || typeof pins !== "object" || Array.isArray(pins)) bad("not an object");
|
|
42
|
+
const { host, keyId, publicKey, brokerDigest } = pins;
|
|
43
|
+
if (typeof host !== "string" || !host.trim()) bad("host is missing");
|
|
44
|
+
if (typeof publicKey !== "string" || !HEX.test(publicKey) || publicKey.length !== 130 || !publicKey.startsWith("04")) bad("publicKey is not an uncompressed SEC1 P-256 point in lowercase hex");
|
|
45
|
+
if (typeof keyId !== "string" || keyId !== sha256(Buffer.from(publicKey, "hex"))) bad("keyId is not the sha256 of the public key");
|
|
46
|
+
if (typeof brokerDigest !== "string" || !/^[0-9a-f]{64}$/.test(brokerDigest)) bad("brokerDigest is not a sha256 in lowercase hex");
|
|
47
|
+
return { host, keyId, publicKey, brokerDigest };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isLink = (meta) => meta.isSymbolicLink();
|
|
51
|
+
|
|
52
|
+
/** The enrolled record, or null when none exists. A link, a non-file, another platform's record or a malformed one is a refusal, never null. */
|
|
53
|
+
export function readEnrolledPins({ dataRoot, platform = process.platform }) {
|
|
54
|
+
const file = pinsFile({ dataRoot, platform });
|
|
55
|
+
let meta;
|
|
56
|
+
try { meta = fs.lstatSync(file); } catch (e) { if (e.code === "ENOENT") return null; throw e; }
|
|
57
|
+
if (isLink(meta) || !meta.isFile()) fail("RK_BROKER_PINS_INVALID", `${file} is not a regular file`);
|
|
58
|
+
const dir = fs.lstatSync(path.dirname(file));
|
|
59
|
+
if (isLink(dir) || !dir.isDirectory()) fail("RK_BROKER_PINS_INVALID", `${path.dirname(file)} is not a real directory`);
|
|
60
|
+
if (process.platform !== "win32" && (meta.mode & 0o022 || dir.mode & 0o022)) fail("RK_BROKER_PINS_INVALID", `${file} or its directory is writable by group or others`);
|
|
61
|
+
let record;
|
|
62
|
+
try { record = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { fail("RK_BROKER_PINS_INVALID", `${file} is not JSON (${e.message})`); }
|
|
63
|
+
if (record?.schema !== PINS_SCHEMA) fail("RK_BROKER_PINS_INVALID", `${file} is not a ${PINS_SCHEMA} record`);
|
|
64
|
+
if (record.platform !== platform) fail("RK_BROKER_PINS_INVALID", `${file} is the record of ${record.platform}, not ${platform}`);
|
|
65
|
+
return { ...validatePins(record, file), source: "enrolled", file };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const git = (spawn, cwd, args) => {
|
|
69
|
+
const r = spawn("git", args, { cwd, encoding: "utf8" });
|
|
70
|
+
if (r.error || r.status !== 0) return null;
|
|
71
|
+
return String(r.stdout ?? "").trim();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* RightKit's own pins, from the revision of the running gate: the repository that holds `gateFile` and its HEAD commit, only when
|
|
76
|
+
* that repository IS the checkout being compiled (RightKit itself). Null when the gate is not run from a RightKit checkout.
|
|
77
|
+
*/
|
|
78
|
+
export function readGateRevisionPins({ gateFile, checkout, platform = process.platform, spawn = spawnSync, realpath = (p) => fs.realpathSync.native(p) }) {
|
|
79
|
+
const here = path.dirname(gateFile);
|
|
80
|
+
const top = git(spawn, here, ["rev-parse", "--show-toplevel"]);
|
|
81
|
+
if (!top) return null;
|
|
82
|
+
let same = false;
|
|
83
|
+
try { same = realpath(top) === realpath(checkout); } catch { same = false; }
|
|
84
|
+
if (!same) return null;
|
|
85
|
+
const revision = git(spawn, here, ["rev-parse", "--verify", "HEAD^{commit}"]);
|
|
86
|
+
if (!revision || !/^[0-9a-f]{40,64}$/.test(revision)) fail("RK_BROKER_PINS_INVALID", "the running gate's revision cannot be read");
|
|
87
|
+
const blob = spawn("git", ["show", `${revision}:${PIPELINE_FILE}`], { cwd: here, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
88
|
+
if (blob.error || blob.status !== 0) return null;
|
|
89
|
+
let pipeline;
|
|
90
|
+
try { pipeline = JSON.parse(String(blob.stdout)); } catch (e) { fail("RK_BROKER_PINS_INVALID", `${PIPELINE_FILE} at the gate's revision ${revision.slice(0, 12)} is not JSON (${e.message})`); }
|
|
91
|
+
if (pipeline?.schema !== PIPELINE_SCHEMA) fail("RK_BROKER_PINS_INVALID", `${PIPELINE_FILE} at the gate's revision is not ${PIPELINE_SCHEMA}`);
|
|
92
|
+
const entry = pipeline.attestation?.[platform];
|
|
93
|
+
if (!entry) return null;
|
|
94
|
+
return { ...validatePins(entry, `${PIPELINE_FILE} (${platform}) at the gate's revision ${revision.slice(0, 12)}`), source: "rk-pipeline", revision };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The pins this gate verifies broker receipts against: the enrolled record, else (RightKit only) the running gate's own revision.
|
|
99
|
+
* Throws RK_BROKER_PINS_MISSING when neither exists: an app repository without an enrollment compiles nothing through a broker.
|
|
100
|
+
*/
|
|
101
|
+
export function loadBrokerPins({ dataRoot, platform = process.platform, gateFile, checkout, spawn, realpath }) {
|
|
102
|
+
const enrolled = readEnrolledPins({ dataRoot, platform });
|
|
103
|
+
if (enrolled) return enrolled;
|
|
104
|
+
const fallback = gateFile && checkout ? readGateRevisionPins({ gateFile, checkout, platform, spawn, realpath }) : null;
|
|
105
|
+
if (fallback) return fallback;
|
|
106
|
+
return fail("RK_BROKER_PINS_MISSING", `no broker enrollment for ${platform} (${pinsFile({ dataRoot, platform })}), and this is not the RightKit checkout whose gate revision carries ${PIPELINE_FILE} pins; enroll the host's broker explicitly, the gate never enrolls one itself`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The explicit enroll operation. Values come from the caller (the output of `rustright-broker --generate-attestation-key` and the
|
|
111
|
+
* installed runtime's `brokerDigest`); nothing is read from the broker, its key file or the environment. An existing record is
|
|
112
|
+
* replaced only with `rotate: true`.
|
|
113
|
+
*/
|
|
114
|
+
export function enrollBrokerPins({ dataRoot, platform = process.platform, host, keyId, publicKey, brokerDigest, rotate = false, now = () => new Date() }) {
|
|
115
|
+
const pins = validatePins({ host, keyId, publicKey, brokerDigest }, "the enrollment");
|
|
116
|
+
const file = pinsFile({ dataRoot, platform });
|
|
117
|
+
const dir = path.dirname(file);
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
119
|
+
const meta = fs.lstatSync(dir);
|
|
120
|
+
if (isLink(meta) || !meta.isDirectory()) fail("RK_BROKER_PINS_INVALID", `${dir} is not a real directory`);
|
|
121
|
+
let existing = null;
|
|
122
|
+
try { existing = readEnrolledPins({ dataRoot, platform }); } catch (e) { if (!rotate) throw e; }
|
|
123
|
+
if (existing && !rotate) fail("RK_BROKER_PINS_EXISTS", `${file} already enrolls key ${existing.keyId.slice(0, 12)}; replacing it is a rotation (pass rotate: true)`);
|
|
124
|
+
const record = { schema: PINS_SCHEMA, platform, ...pins, enrolledAt: now().toISOString(), ...(existing ? { rotatedFrom: { keyId: existing.keyId, brokerDigest: existing.brokerDigest } } : {}) };
|
|
125
|
+
const tmp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
126
|
+
fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
127
|
+
try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
|
|
128
|
+
return { file, record, rotated: Boolean(existing) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `node broker-pins.mjs enroll --platform <darwin|win32> --data-root <dir> --host H --key-id K --public-key P --broker-digest D [--rotate]` or `show`. */
|
|
132
|
+
export function main(argv, { out = (m) => process.stdout.write(`${m}\n`), err = (m) => process.stderr.write(`${m}\n`) } = {}) {
|
|
133
|
+
const [command, ...rest] = argv;
|
|
134
|
+
const opt = (name) => { const i = rest.indexOf(name); return i < 0 ? undefined : rest[i + 1]; };
|
|
135
|
+
try {
|
|
136
|
+
const dataRoot = opt("--data-root"); const platform = opt("--platform") ?? process.platform;
|
|
137
|
+
if (command === "enroll") {
|
|
138
|
+
const r = enrollBrokerPins({ dataRoot, platform, host: opt("--host"), keyId: opt("--key-id"), publicKey: opt("--public-key"), brokerDigest: opt("--broker-digest"), rotate: rest.includes("--rotate") });
|
|
139
|
+
out(`enrolled ${r.record.platform} ${r.record.host} key ${r.record.keyId} in ${r.file}${r.rotated ? " (rotation)" : ""}`);
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
if (command === "show") {
|
|
143
|
+
const pins = readEnrolledPins({ dataRoot, platform });
|
|
144
|
+
out(pins ? JSON.stringify(pins) : "no enrollment");
|
|
145
|
+
return pins ? 0 : 1;
|
|
146
|
+
}
|
|
147
|
+
err("usage: broker-pins.mjs enroll|show --platform <darwin|win32> --data-root <dir> [--host H --key-id K --public-key P --broker-digest D [--rotate]]");
|
|
148
|
+
return 2;
|
|
149
|
+
} catch (e) { err(`${e.code ?? "error"}: ${e.message}`); return 1; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (process.argv[1] && (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })()) process.exit(main(process.argv.slice(2)));
|
package/build-release.mjs
CHANGED
|
@@ -26,8 +26,10 @@ import { formatAdmission, formatNotRun, runAdmission } from "./admission.mjs";
|
|
|
26
26
|
import { sccacheRefusal } from "./admission-host.mjs";
|
|
27
27
|
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import {
|
|
29
|
+
import { dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
|
|
30
|
+
import { assertInsideSource, assertReleaseSource, gitRootOf, isSnapshotSource, qaAdmissionSourceOptions, resolveReleaseSource, sourceRootOf } from "./release-source-identity.mjs";
|
|
30
31
|
import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
32
|
+
import { ArtifactPathError, assertSplitCommandPaths, boundTargetsEvidence, releaseCommandSteps, resolveArtifactPath as resolveSplitArtifactPath, splitArtifactContext } from "./release-artifact-path.mjs";
|
|
31
33
|
import { admitSourceQa } from "./release-qa-audit.mjs";
|
|
32
34
|
import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
|
|
33
35
|
import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
|
|
@@ -44,7 +46,6 @@ import { collectSwiftToolVersions, planSwiftBuild } from "./swift-build-context.
|
|
|
44
46
|
import { planIosNativeBuild, resolveIosBuildContext } from "./ios-build-context.mjs";
|
|
45
47
|
import { packageCommandOptional, requiredBuildExecutables, resolveProjectType, targetUsesMsix } from "./project-type.mjs";
|
|
46
48
|
import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
|
|
47
|
-
import { assertCleanSource } from "./source-gate.mjs";
|
|
48
49
|
import { acquireHeavyWorkSlot, heavyCommandEnvironment, summarizePreBrokerWaits, terminateProcessTree } from "./heavy-command.mjs";
|
|
49
50
|
import { processGroupCpuSeconds } from "./process-liveness.mjs";
|
|
50
51
|
import { cancelAndReapManagedRequest, firstManagedRequestId, releaseProgressLimits } from "./progress-control.mjs";
|
|
@@ -80,7 +81,7 @@ const ANDROID_PIPELINE_FINGERPRINT = createHash("sha256")
|
|
|
80
81
|
.slice(0, 16);
|
|
81
82
|
|
|
82
83
|
/** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
|
|
83
|
-
function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target, pinnedCargoLocks = [] }) {
|
|
84
|
+
function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target, pinnedCargoLocks = [], releaseSource = null, sourceRoot = repoRoot, artifactPaths = null }) {
|
|
84
85
|
let version;
|
|
85
86
|
try {
|
|
86
87
|
version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
|
|
@@ -88,12 +89,15 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativ
|
|
|
88
89
|
version = undefined;
|
|
89
90
|
}
|
|
90
91
|
// An iOS target is built from its Xcode project and local packages only; the ownership scan covers just those roots.
|
|
91
|
-
|
|
92
|
+
// In a split the scope is the snapshot's (the commit's layout); the preflight reads the checkout for git, the sealed vault and the evidence.
|
|
93
|
+
const ownershipScope = preflightOwnershipScope({ repoRoot: sourceRoot, appRoot, platform, target, nativeAssembly: config?.nativeAssembly });
|
|
92
94
|
return collectPreflight({
|
|
93
95
|
platform,
|
|
94
96
|
ownershipScope,
|
|
95
97
|
appRoot,
|
|
96
98
|
repoRoot,
|
|
99
|
+
releaseSource,
|
|
100
|
+
splitTargets: artifactPaths,
|
|
97
101
|
app: config.app,
|
|
98
102
|
configPath,
|
|
99
103
|
// The version check is the doctor's job: by the time `build` runs, seal
|
|
@@ -140,10 +144,24 @@ if (resumeFrom && !["prepackage", "sign", "package", "post-sign", "harden"].incl
|
|
|
140
144
|
const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
|
|
141
145
|
const configPath = nativeReal(path.resolve(configName));
|
|
142
146
|
const invocationRoot = path.dirname(configPath);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
// Source identity (release-source-identity.mjs, item 7 row 7.04, decision 2 Q2). Without RK_GATE_CHECKOUT the source is the checkout's HEAD,
|
|
148
|
+
// proven exactly as before (primary worktree, branch-attached HEAD, clean status). Inside a door run it is the SELECTED COMMIT's objects
|
|
149
|
+
// plus the gate's verified snapshot: the checkout may be dirty, ahead or detached, and no HEAD, status or branch of it is read.
|
|
150
|
+
// A split that cannot be proven is refused (never a fallback to the checkout checks).
|
|
151
|
+
let releaseSource;
|
|
152
|
+
try { releaseSource = resolveReleaseSource({ cwd: process.env.RK_GATE_CHECKOUT ? process.cwd() : invocationRoot }); }
|
|
153
|
+
catch (error) { fail(error.code === "RK_SOURCE_NO_REPOSITORY" ? `git rev-parse --show-toplevel failed: ${error.message}` : `${error.code ?? "RK_SOURCE_IDENTITY"}: ${error.message}`); }
|
|
154
|
+
const splitSource = isSnapshotSource(releaseSource);
|
|
155
|
+
// The checkout: every object read and every output (`.right-release`: vault, state, locks, sealed) belongs to it (GC:31, GC:65). The
|
|
156
|
+
// source root is where the config, the build inputs and the QA plan are read: the snapshot in a split, else the checkout itself.
|
|
157
|
+
const repoRoot = gitRootOf(releaseSource);
|
|
158
|
+
const sourceRoot = sourceRootOf(releaseSource);
|
|
159
|
+
try { assertReleaseSource(releaseSource, { commandId: "right-release build", skipCleanCheck: true }); } catch (error) { fail(error.message); }
|
|
160
|
+
try { assertInsideSource(releaseSource, configPath, "the release config"); } catch (error) { fail(error.message); }
|
|
161
|
+
const relativeConfig = path.relative(nativeReal(sourceRoot), nativeReal(configPath)).replaceAll("\\", "/");
|
|
162
|
+
if (relativeConfig.startsWith("../")) fail(splitSource ? "release config must live inside the snapshot" : "release config must live inside the Git repository");
|
|
163
|
+
// A split reads no dirty-checkout condition: the snapshot is the commit's bytes, proven by the door at the start and end of the stage.
|
|
164
|
+
const sourceProblems = (inputs, commitId) => (splitSource ? [] : dirtyBuildInputs(repoRoot, inputs, commitId));
|
|
147
165
|
const appRoot = path.dirname(configPath);
|
|
148
166
|
const layout = resolveReleaseLayout({ repoRoot, configPath });
|
|
149
167
|
const vaultRoot = layout.vaultRoot;
|
|
@@ -182,20 +200,17 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
|
182
200
|
|
|
183
201
|
try {
|
|
184
202
|
throwIfInterrupted();
|
|
185
|
-
const commit =
|
|
203
|
+
const commit = releaseSource.commit;
|
|
186
204
|
// A phase-resume never reads unrelated source. Its later build-input gate
|
|
187
205
|
// remains strict, but requiring a globally clean mono-workspace here would
|
|
188
206
|
// turn an already-built, signed installer into a needless full rebuild.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
commandId: "right-release build",
|
|
193
|
-
});
|
|
194
|
-
}
|
|
207
|
+
// The checkout route runs the clean-source gate here (R1, byte for byte as before); a split re-proves its door run instead and reads
|
|
208
|
+
// no status (R2: a split that is no longer proven refuses, it never falls back).
|
|
209
|
+
try { assertReleaseSource(releaseSource, { commandId: "right-release build", skipPrimaryCheckout: true, skipCleanCheck: Boolean(resumeFrom) }); } catch (error) { fail(error.message); }
|
|
195
210
|
mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
|
|
196
211
|
lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
|
|
197
212
|
const shortCommit = commit.slice(0, 8);
|
|
198
|
-
const dirtyConfig =
|
|
213
|
+
const dirtyConfig = sourceProblems({ include: [relativeConfig], required: [relativeConfig] }, commit);
|
|
199
214
|
if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
|
|
200
215
|
const config = await importReleaseConfig(configPath, { query: `commit=${commit}` });
|
|
201
216
|
if (!config?.app || !config?.version) fail("release config must expose app and version");
|
|
@@ -224,11 +239,25 @@ try {
|
|
|
224
239
|
: null;
|
|
225
240
|
const nativeIos = iosContext?.nativeIos ?? false;
|
|
226
241
|
const nativeLayout = iosContext ? iosContext.nativeLayout : resolveNativeCargoLayout({ appRoot, config });
|
|
242
|
+
// Row 7.04c. In a door split the snapshot has no target directory: (R-T4) no step the app owns may name a target-relative path, refused here, before
|
|
243
|
+
// anything builds; (R-T1) each Cargo manifest's target is bound once, now, through the published resolver. Checkout mode does neither.
|
|
244
|
+
let artifactPaths = null;
|
|
245
|
+
if (splitSource) {
|
|
246
|
+
try {
|
|
247
|
+
assertSplitCommandPaths(releaseCommandSteps({ target, config, root: appRoot }), splitArtifactContext({ cwd: appRoot, env: process.env, nativeLayout, config, target, snapshotRoot: sourceRoot, resolve: false }));
|
|
248
|
+
artifactPaths = splitArtifactContext({
|
|
249
|
+
cwd: appRoot, nativeLayout, config, target, snapshotRoot: sourceRoot,
|
|
250
|
+
env: { ...process.env, RIGHT_RELEASE_ARTIFACT_DIR: resolveArtifactDir({ app: config.app, version: config.version, managedRoot: resolveManagedRoot({ env: process.env }) }) },
|
|
251
|
+
});
|
|
252
|
+
} catch (error) { if (error instanceof ArtifactPathError) fail(error.message); throw error; }
|
|
253
|
+
}
|
|
227
254
|
if (platform === "win" && target.signingContract === "windows-raw-exe-authenticode-before-nsis-v1") {
|
|
228
255
|
assertNsisInPlaceUpgradeContract(appRoot, target.nsisUpgradeContract);
|
|
229
256
|
}
|
|
230
|
-
const { requiredInputs, buildInputs, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target });
|
|
231
|
-
|
|
257
|
+
const { requiredInputs, buildInputs, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({ repoRoot: sourceRoot, appRoot, configPath, config, target });
|
|
258
|
+
// R5: in a split every input resolves inside the snapshot (`hashInputs` below hashes the snapshot's files); the dirty-input gate is the checkout route's.
|
|
259
|
+
for (const file of requiredInputs) try { assertInsideSource(releaseSource, file, "a release build input"); } catch (error) { fail(error.message); }
|
|
260
|
+
const dirtyInputs = sourceProblems(buildInputs, commit);
|
|
232
261
|
if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
|
|
233
262
|
const hasPackageJson = existsSync(path.join(appRoot, "package.json"));
|
|
234
263
|
// iOS records the Xcode toolchain (and xcodegen when a project.yml is named) beside the Node and Rust tools.
|
|
@@ -316,7 +345,7 @@ try {
|
|
|
316
345
|
// Preflight BEFORE any compile. These preconditions used to be discovered one
|
|
317
346
|
// per build cycle, minutes deep, with errors that named a symptom instead of a
|
|
318
347
|
// cause. Blocking problems stop here, in seconds, naming the fix.
|
|
319
|
-
const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout, target, pinnedCargoLocks });
|
|
348
|
+
const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout, target, pinnedCargoLocks, releaseSource, sourceRoot, artifactPaths });
|
|
320
349
|
const preflightBlockers = preflightFailures(preflight);
|
|
321
350
|
if (preflightBlockers.length > 0) {
|
|
322
351
|
fail(`preflight found ${preflightBlockers.length} blocking problem(s):\n${formatPreflight(preflightBlockers)}`);
|
|
@@ -331,7 +360,8 @@ try {
|
|
|
331
360
|
}
|
|
332
361
|
// Hardening step 4: source QA is recorded, not enforced. A missing or rejected receipt warns loudly and the outcome
|
|
333
362
|
// is sealed into the manifest (`qa.status`) for `right-release qa audit`. Dev lanes (install-app --dev) never reach this file.
|
|
334
|
-
|
|
363
|
+
// A split compares the source QA receipt with the SELECTED commit (its tree, repository identity and lockfile closure from its objects), not HEAD.
|
|
364
|
+
const qaAdmission = admitSourceQa({ repoRoot: layout.repoRoot, releaseId, platform, configName: path.relative(process.cwd(), configPath) || path.basename(configPath), releaseConfigSha256: hashFile(configPath), ...qaAdmissionSourceOptions(releaseSource) });
|
|
335
365
|
|
|
336
366
|
// A tool being INSTALLED is not the same as it being the one PATH picks. The
|
|
337
367
|
// vendored-OpenSSL build shells out to `perl`, and under Git Bash/MSYS that
|
|
@@ -343,6 +373,7 @@ try {
|
|
|
343
373
|
...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
|
|
344
374
|
...(pathPrefix ? { PATH: `${pathPrefix}${path.delimiter}${process.env.PATH ?? ""}` } : {}),
|
|
345
375
|
RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
|
|
376
|
+
...(splitSource ? { RIGHT_RELEASE_SOURCE_ROOT: sourceRoot } : {}),
|
|
346
377
|
RIGHT_RELEASE_APP_ROOT: layout.appRoot,
|
|
347
378
|
RIGHTKIT_MANAGED_ROOT: resolveManagedRoot({ env: process.env }),
|
|
348
379
|
RIGHT_RELEASE_ARTIFACT_DIR: resolveArtifactDir({ app: config.app, version: config.version, managedRoot: resolveManagedRoot({ env: process.env }) }),
|
|
@@ -353,6 +384,9 @@ try {
|
|
|
353
384
|
delete env[name];
|
|
354
385
|
}
|
|
355
386
|
}
|
|
387
|
+
// A split's Cargo target is the one the gate bound and handed this process (row 7.09, R-T1): Cache V2's shared target does not replace it, or
|
|
388
|
+
// the bound target the seal reads would not be the one Cargo wrote.
|
|
389
|
+
if (splitSource && !managedCargoTarget && typeof process.env.CARGO_TARGET_DIR === "string" && path.isAbsolute(process.env.CARGO_TARGET_DIR)) env.CARGO_TARGET_DIR = process.env.CARGO_TARGET_DIR;
|
|
356
390
|
if (cacheMode !== "shared") delete env.RIGHT_RELEASE_CACHE_OWNER;
|
|
357
391
|
if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
|
|
358
392
|
if (cacheMode !== "shared" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
|
|
@@ -495,6 +529,9 @@ try {
|
|
|
495
529
|
// cost ~5.9 GB of warm broker intermediates. The bridge's brokerManaged
|
|
496
530
|
// flag already knows to leave a real directory untouched instead.
|
|
497
531
|
if (swiftPlan) { /* SwiftPM: no Rust target link */ }
|
|
532
|
+
// A split writes no link into the snapshot (a link is never a ledger entry, decision 2 Q1 rule 2): Cargo writes the cache target
|
|
533
|
+
// directly (CARGO_TARGET_DIR) and the seal resolves `src-tauri/target/...` paths against it (sealRelease managedCargoTarget).
|
|
534
|
+
else if (splitSource) { /* no target link in the snapshot */ }
|
|
498
535
|
else if (cacheMode === "shared" || managedCargoTarget) targetBridge.ensure();
|
|
499
536
|
else {
|
|
500
537
|
const linked = ensureLegacyTargetLink({ targetLink, cacheTarget, fs: { existsSync, realpathSync, renameSync, symlinkSync } });
|
|
@@ -538,7 +575,7 @@ try {
|
|
|
538
575
|
});
|
|
539
576
|
},
|
|
540
577
|
seal: async ({ sealedDir }) => {
|
|
541
|
-
sealRelease({ configRoot: appRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields: rightKitResolution ? registryManifestFields(rightKitResolution.snapshot, RELEASE_TOOL_VERSION) : undefined });
|
|
578
|
+
sealRelease({ configRoot: appRoot, managedCargoTarget, artifactPaths, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields: rightKitResolution ? registryManifestFields(rightKitResolution.snapshot, RELEASE_TOOL_VERSION) : undefined, splitSealSource: splitSource ? { commit: releaseSource.commit, tree: releaseSource.tree, snapshotAppRoot: appRoot, checkoutAppRoot: releaseSource.checkoutAppRoot } : null });
|
|
542
579
|
verifySealedRelease(sealedDir);
|
|
543
580
|
if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
|
|
544
581
|
checkpoint(stateRoot, "sealed");
|
|
@@ -565,8 +602,8 @@ async function admit() {
|
|
|
565
602
|
let commit;
|
|
566
603
|
let config;
|
|
567
604
|
try {
|
|
568
|
-
commit =
|
|
569
|
-
const dirtyConfig =
|
|
605
|
+
commit = releaseSource.commit;
|
|
606
|
+
const dirtyConfig = sourceProblems({ include: [relativeConfig], required: [relativeConfig] }, commit);
|
|
570
607
|
if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
|
|
571
608
|
try { config = await importReleaseConfig(configPath, { query: `commit=${commit}` }); } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
|
|
572
609
|
} catch (error) {
|
|
@@ -579,7 +616,7 @@ async function admit() {
|
|
|
579
616
|
&& existsSync(path.join(repoRoot, ".right-release", "sealed", `${config.app}-${config.version}-${commit.slice(0, 8)}`, platformName, "release-manifest.json"));
|
|
580
617
|
const reuse = Boolean(resumeFrom) || Boolean(sealed);
|
|
581
618
|
const result = await runAdmission({
|
|
582
|
-
root: appRoot, repoRoot, configPath, config: config ?? {}, platform, commit, skipChecks,
|
|
619
|
+
root: appRoot, repoRoot: sourceRoot, releaseSource, configPath, config: config ?? {}, platform, commit, skipChecks,
|
|
583
620
|
tiers: reuse ? ["files", "host"] : ["files", "host", "network"],
|
|
584
621
|
registry: async (ctx) => {
|
|
585
622
|
const inputs = ctx.buildInputs();
|
|
@@ -631,7 +668,7 @@ function sqlCipherFeatures(text) {
|
|
|
631
668
|
.filter((value) => /sqlcipher|openssl/i.test(value));
|
|
632
669
|
}
|
|
633
670
|
|
|
634
|
-
function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields }) {
|
|
671
|
+
function sealRelease({ configRoot, managedCargoTarget, artifactPaths = null, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields, splitSealSource = null }) {
|
|
635
672
|
// Every app's release config names installer/updater artifacts
|
|
636
673
|
// relative to its resolved native target. On a broker-managed host that
|
|
637
674
|
// directory is never populated (the broker owns CARGO_TARGET_DIR and
|
|
@@ -642,7 +679,11 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
|
|
|
642
679
|
const targetPrefix = "src-tauri/target/";
|
|
643
680
|
// A Cargo-less iOS app has no native layout and no managed cargo target: only the default prefix is ever compared.
|
|
644
681
|
const effectiveTargetPrefix = nativeLayout?.targetPrefix ?? targetPrefix;
|
|
645
|
-
const resolveArtifactPath = (file) => {
|
|
682
|
+
const resolveArtifactPath = artifactPaths?.mode === "split" ? (file) => {
|
|
683
|
+
// Row 7.04c (R-T2): a split's declared artifact is read in place in the bound target (or the snapshot, or the managed artifact directory),
|
|
684
|
+
// through the one resolver; the prefix remap below is the checkout route's.
|
|
685
|
+
try { return resolveSplitArtifactPath(file, artifactPaths); } catch (error) { if (error instanceof ArtifactPathError) fail(error.message); throw error; }
|
|
686
|
+
} : (file) => {
|
|
646
687
|
const normalized = file.replaceAll(path.win32.sep, "/");
|
|
647
688
|
if (managedCargoTarget && normalized.startsWith(targetPrefix)) {
|
|
648
689
|
return path.join(managedCargoTarget, normalized.slice(targetPrefix.length));
|
|
@@ -793,6 +834,10 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
|
|
|
793
834
|
app: config.app,
|
|
794
835
|
version: config.version,
|
|
795
836
|
commit,
|
|
837
|
+
// A split seal names the selected commit AND its tree and says its bytes came from the door's snapshot (decision 2 Q2 item 5).
|
|
838
|
+
...(splitSealSource ? { tree: splitSealSource.tree, source: "snapshot" } : {}),
|
|
839
|
+
// The Cargo targets this split bound (R-T1); the door cross-checks them against its own resolution (RK_SPLIT_TARGET_DRIFT).
|
|
840
|
+
...(artifactPaths?.mode === "split" ? { boundTargets: boundTargetsEvidence(artifactPaths) } : {}),
|
|
796
841
|
ownershipEvidence,
|
|
797
842
|
// The exact registry resolution (identities, digests, registries, policy revision, observation time, observed latest)
|
|
798
843
|
// this release was built against. Upload and resume consume it; neither resolves again.
|
|
@@ -814,7 +859,15 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
|
|
|
814
859
|
if (present.length !== signingIdentity.receiptInputs.length) fail("missing current-build macOS signing or notarization receipt");
|
|
815
860
|
validateNotarizationReceipt({ receiptPath: signingIdentity.receiptInputs[1], archive: resolveArtifactPath(target.notarize.file) });
|
|
816
861
|
}
|
|
817
|
-
|
|
862
|
+
// A split records each receipt relative to a base the checkout reproduces (its app root), never the snapshot's: the snapshot's bytes are
|
|
863
|
+
// replaced at the next sync. A receipt written INSIDE the snapshot cannot be re-read later, so it is refused (configure sign.receipt under the state root).
|
|
864
|
+
const recorded = (file) => {
|
|
865
|
+
if (!splitSealSource) return file;
|
|
866
|
+
const rel = (base) => path.relative(base, file).replaceAll("\\", "/");
|
|
867
|
+
if (!rel(splitSealSource.snapshotAppRoot).startsWith("../") && rel(splitSealSource.snapshotAppRoot) !== "..") fail(`signing receipt ${file} is inside the snapshot, which the next sync replaces; write it under the state root (${stateRoot}) so the seal can name it`);
|
|
868
|
+
return rel(splitSealSource.checkoutAppRoot);
|
|
869
|
+
};
|
|
870
|
+
return present.map((file) => ({ file: recorded(file), sha256: hashFile(file) }));
|
|
818
871
|
})() } : null,
|
|
819
872
|
files,
|
|
820
873
|
routes,
|
package/cargo-ledger.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The gate-owned ledger of every call the runtime cargo wrapper decides (item 7 rows 7.10 and 7.24, Sage decision C).
|
|
2
|
+
//
|
|
3
|
+
// The wrapper (cargo-target-shim.mjs) appends one record BEFORE it acts (`pending`) and one after (`ran`, `verified`, `unverified` or
|
|
4
|
+
// `refused`), to `<ledger dir>/<scan token>.jsonl`. The gate (runScans) reads the file of each scan after it ended and fails the scan
|
|
5
|
+
// on anything but a completed, successful call: a refused call fails its unit even when the test swallowed cargo's exit status, and a
|
|
6
|
+
// call that never completed (the wrapper was killed, or the record was never finished) is unverified, never skipped. Records the gate
|
|
7
|
+
// cannot attribute to a scan (the scan token was cleared) fail the run as a row of their own.
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
export const LEDGER_SCHEMA = "rightkit.cargo-ledger/1";
|
|
13
|
+
export const SCAN_TOKEN_ENV = "RIGHTKIT_CARGO_SCAN";
|
|
14
|
+
export const UNSCOPED = "unscoped";
|
|
15
|
+
/** Statuses that are a completed success: a direct call that ran, a broker call whose signed receipt verified. */
|
|
16
|
+
export const OK_STATUSES = Object.freeze(["ran", "verified"]);
|
|
17
|
+
|
|
18
|
+
/** The token a scan's calls are filed under: stable per scan id, never the id itself. */
|
|
19
|
+
export const scanTokenOf = (scanId) => crypto.createHash("sha256").update(String(scanId)).digest("hex").slice(0, 16);
|
|
20
|
+
|
|
21
|
+
const tokenFile = (dir, token) => path.join(dir, `${token}.jsonl`);
|
|
22
|
+
const validToken = (token) => (typeof token === "string" && /^[0-9a-f]{16}$/.test(token) ? token : UNSCOPED);
|
|
23
|
+
|
|
24
|
+
/** Appends one record. Throws when it cannot be written: a call that cannot be recorded does not run. */
|
|
25
|
+
export function appendLedger(dir, token, entry) {
|
|
26
|
+
if (!dir || !path.isAbsolute(dir)) throw new Error("the cargo ledger directory is not set");
|
|
27
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
28
|
+
const fd = fs.openSync(tokenFile(dir, validToken(token)), fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
29
|
+
try { fs.writeSync(fd, `${JSON.stringify({ schema: LEDGER_SCHEMA, at: new Date().toISOString(), ...entry })}\n`); } finally { fs.closeSync(fd); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The calls filed under `token`, one per call id (a later record replaces an earlier one), and the problems found: an unreadable
|
|
34
|
+
* line, and every call that is not a completed success.
|
|
35
|
+
*/
|
|
36
|
+
export function readLedger(dir, token) {
|
|
37
|
+
const file = tokenFile(dir, validToken(token));
|
|
38
|
+
let text = "";
|
|
39
|
+
try { text = fs.readFileSync(file, "utf8"); } catch (e) { if (e.code === "ENOENT") return { entries: [], problems: [] }; throw e; }
|
|
40
|
+
const byId = new Map(); const problems = [];
|
|
41
|
+
for (const line of text.split("\n")) {
|
|
42
|
+
if (!line.trim()) continue;
|
|
43
|
+
let entry;
|
|
44
|
+
try { entry = JSON.parse(line); } catch { problems.push(`an unreadable ledger record in ${path.basename(file)}`); continue; }
|
|
45
|
+
if (entry?.schema !== LEDGER_SCHEMA || typeof entry.id !== "string") { problems.push(`a malformed ledger record in ${path.basename(file)}`); continue; }
|
|
46
|
+
byId.set(entry.id, { ...(byId.get(entry.id) ?? {}), ...entry });
|
|
47
|
+
}
|
|
48
|
+
const entries = [...byId.values()];
|
|
49
|
+
for (const e of entries) {
|
|
50
|
+
if (OK_STATUSES.includes(e.status)) continue;
|
|
51
|
+
const call = `cargo ${(e.argv ?? []).join(" ")}`.trim();
|
|
52
|
+
problems.push(e.status === "pending" ? `${call} (from ${e.cwd}) never completed: the wrapper recorded it and no outcome, so it is unverified` : `${call} (from ${e.cwd}) was ${e.status}${e.code ? ` (${e.code})` : ""}${e.message ? `: ${e.message}` : ""}`);
|
|
53
|
+
}
|
|
54
|
+
return { entries, problems };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Ledger files nobody claimed: the scan token was cleared or invented by the code under test. */
|
|
58
|
+
export function unclaimedLedgers(dir, claimedTokens) {
|
|
59
|
+
let names = [];
|
|
60
|
+
try { names = fs.readdirSync(dir); } catch (e) { if (e.code === "ENOENT") return []; throw e; }
|
|
61
|
+
return names.filter((n) => n.endsWith(".jsonl")).map((n) => n.slice(0, -".jsonl".length)).filter((t) => !claimedTokens.has(t));
|
|
62
|
+
}
|