@rightkit/release 0.6.13 → 0.6.15
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/rightkit-gate.mjs +1193 -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 +112 -26
- 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 +161 -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/pipeline-receipts.mjs +302 -302
- 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 +15 -11
- 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/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +0 -6
package/build-ownership.mjs
CHANGED
|
@@ -44,11 +44,12 @@
|
|
|
44
44
|
// unreadable stat: the process is held. Residual on POSIX, stated plainly: a descendant that started a
|
|
45
45
|
// new session, dropped the variable, runs outside the snapshot directory, names it in no argument AND holds no file in it
|
|
46
46
|
// open or mapped at the instant the nets sample it (it reads between samples) is invisible; a sampled net cannot prove
|
|
47
|
-
// such a process will not read next. (On macOS
|
|
48
|
-
//
|
|
47
|
+
// such a process will not read next. (On macOS a hidden-environment process is attributed by no start time: it is reported in
|
|
48
|
+
// the output and never held; rightkit#1313.) Another user's unreadable processes are
|
|
49
49
|
// skipped on the premise that they cannot read this user's managed root.
|
|
50
50
|
// Any host: a reboot after the record was written ends every process it covered.
|
|
51
|
-
// - if a
|
|
51
|
+
// - if a carried (snapshot-bound) request was sent under the handle (`broker.submitted`, set only just before such a send; an uncarried
|
|
52
|
+
// probe or an installed carrier map never sets it), `rustright snapshot-lease status` reports REAPED for that snapshot & handle.
|
|
52
53
|
// Any other answer (alive, a probe that fails or is unsupported, ACTIVE, UNKNOWN, a record or holder state that cannot be read)
|
|
53
54
|
// is busy: never a reclaim. The same rules decide a normal release: the record is removed only when nothing but the gate itself
|
|
54
55
|
// is left; otherwise it stays, and the next gate reclaims it once the survivors are gone.
|
|
@@ -64,12 +65,17 @@ import { spawn as spawnDetachedDefault, spawnSync } from "./background-process.m
|
|
|
64
65
|
import { managedRuntimeClient } from "./managed-cargo.mjs";
|
|
65
66
|
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
66
67
|
import { requireManagedRoot } from "./managed-root-guard.mjs";
|
|
68
|
+
import { OWNERSHIP_ENV, OWNERSHIP_FILE_ENV, OwnershipBusy, holds, markBrokerSubmit, markBrokerUnsent, markCarriedSend, markLockFile } from "./ownership-mark.mjs";
|
|
69
|
+
|
|
70
|
+
// The mark itself lives in a leaf module (ownership-mark.mjs): snapshot-broker.mjs records carried sends through it, and importing this
|
|
71
|
+
// file from there would close a cycle (this file -> managed-cargo -> cargo-target-shim -> snapshot-broker).
|
|
72
|
+
export { OWNERSHIP_ENV, OWNERSHIP_FILE_ENV, OwnershipBusy, markCarriedSend, markLockFile };
|
|
67
73
|
|
|
68
74
|
export const OWNERSHIP_SCHEMA = "rightkit.build-ownership/1";
|
|
69
75
|
export const HOLDER_SCHEMA = "rightkit.build-ownership-holder/1";
|
|
70
|
-
export const OWNERSHIP_ENV = "RIGHTKIT_BUILD_OWNERSHIP";
|
|
71
76
|
/** The owner record's path, exported beside the handle so every process under the ownership records detached children there. */
|
|
72
|
-
|
|
77
|
+
/** GB-85: how long a successful stage's release waits for every submission under its handle to be durably complete (the broker's own bounded `--wait-ms`, never a poll of the build). */
|
|
78
|
+
export const RELEASE_LEASE_WAIT_MS = 15_000;
|
|
73
79
|
export const LEASE_STATUS_SCHEMA = "rustright.snapshot-lease-status.v1";
|
|
74
80
|
const LEASE_STATES = new Set(["ACTIVE", "REAPED", "UNKNOWN"]);
|
|
75
81
|
// A start time read from `ps` (or /proc/<pid>/stat) is never late, only early: ps prints whole seconds (up to 1 s early), and on
|
|
@@ -83,9 +89,6 @@ const START_SLACK_MS = 3000;
|
|
|
83
89
|
const SAME_START_MS = 3000;
|
|
84
90
|
const sameStart = (a, b) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= SAME_START_MS;
|
|
85
91
|
|
|
86
|
-
export class OwnershipBusy extends Error {
|
|
87
|
-
constructor(message, detail = {}) { super(message); this.code = "busy"; this.detail = detail; }
|
|
88
|
-
}
|
|
89
92
|
|
|
90
93
|
/** The directory beside the host's snapshots (or beside any injected root) that holds the owner record. */
|
|
91
94
|
export const OWNERSHIP_DIRNAME = "build-ownership";
|
|
@@ -867,10 +870,22 @@ export function readHolder(file) {
|
|
|
867
870
|
/**
|
|
868
871
|
* RustRight's lease state for a snapshot & ownership handle (`rustright snapshot-lease status`). Anything but a well-formed
|
|
869
872
|
* answer is UNKNOWN: a missing client, a failed or unknown command, a schema or state the gate does not know.
|
|
873
|
+
*
|
|
874
|
+
* The answer is read exactly as the broker writes it (BROKER-CONTRACT section 16, GB-23 and GB-85): `{ type, schema, state, snapshot, ownership, members, waitedMs, waitTimedOut }`.
|
|
875
|
+
* `members` is one row per submission under the handle and `state` is only their aggregate (ACTIVE while any is held, REAPED only with at least one member and every member REAPED, else
|
|
876
|
+
* UNKNOWN); a state that is not the aggregate of its own rows is a contradiction, never a result. A wait was asked with `--wait-ms` (clamped to 30000): the answer then always carries
|
|
877
|
+
* `waitedMs` (a count) and `waitTimedOut` (a boolean, true exactly when the state is not REAPED at the bound). A timeout is UNKNOWN with the reason; the gate never polls and never resubmits.
|
|
878
|
+
* The result carries `members`, `waitedMs` and `waitTimedOut` for the callers that account by name.
|
|
870
879
|
*/
|
|
871
|
-
export function brokerLeaseState({ snapshot, handle, env = process.env, platform = process.platform, spawn = spawnSync, client = managedRuntimeClient(env, platform) }) {
|
|
880
|
+
export function brokerLeaseState({ snapshot, handle, env = process.env, platform = process.platform, spawn = spawnSync, client = managedRuntimeClient(env, platform), waitMs = 0 }) {
|
|
872
881
|
if (!client) return { state: "UNKNOWN", why: "no RustRight client on this host" };
|
|
873
|
-
|
|
882
|
+
// GB-85: `--wait-ms N` makes the broker answer as soon as every submission under the handle is durably complete (REAPED), or at the bound with the state it has
|
|
883
|
+
// (waitTimedOut: true). A client that predates the option answers with a usage error: the plain query is then made, never a guess.
|
|
884
|
+
const clamp = (wait) => Math.min(Math.trunc(wait), 30_000);
|
|
885
|
+
const ask = (wait) => run(spawn, client, ["snapshot-lease", "status", "--snapshot", snapshot, "--ownership", handle, ...(wait > 0 ? ["--wait-ms", String(clamp(wait))] : [])], env);
|
|
886
|
+
let waited = Number.isFinite(waitMs) && waitMs > 0 ? waitMs : 0;
|
|
887
|
+
let r = ask(waited);
|
|
888
|
+
if (waited > 0 && r.status !== 0 && !r.error && /wait-ms|unknown|unexpected|unrecognized|usage/i.test(String(r.stderr ?? ""))) { waited = 0; r = ask(0); }
|
|
874
889
|
if (r.error || r.status !== 0) return { state: "UNKNOWN", why: `rustright snapshot-lease status: ${r.error?.message ?? (String(r.stderr ?? "").trim().split(/\r?\n/).pop() || `exit ${r.status}`)}` };
|
|
875
890
|
let answer = null;
|
|
876
891
|
try { answer = JSON.parse(String(r.stdout ?? "").trim().split(/\r?\n/).pop()); } catch { answer = null; }
|
|
@@ -878,7 +893,25 @@ export function brokerLeaseState({ snapshot, handle, env = process.env, platform
|
|
|
878
893
|
// The answer must echo what was asked (RustRight echoes `snapshot` as received and `ownership` as the handle): another pair's answer,
|
|
879
894
|
// or no echo at all, binds nothing to this owner, so it is UNKNOWN and the record is held.
|
|
880
895
|
if (answer.snapshot !== snapshot || answer.ownership !== handle) return { state: "UNKNOWN", why: `the lease status does not echo the queried snapshot and ownership handle (${JSON.stringify(answer.snapshot ?? null)}, ${JSON.stringify(answer.ownership ?? null)})` };
|
|
881
|
-
|
|
896
|
+
const unknown = (why) => ({ state: "UNKNOWN", why: `rustright snapshot-lease status: ${why}`, ...(Array.isArray(answer.members) ? { members: answer.members } : {}) });
|
|
897
|
+
// GB-23: `members` is one row per submission the broker knows under the handle ({handle, state, spawned, refused?, job?}); the answer's own state is only the aggregate.
|
|
898
|
+
const covers = (h) => h === handle || String(h).startsWith(`${handle}#`);
|
|
899
|
+
if (!Array.isArray(answer.members)) return unknown("the answer carries no members list (one row per submission under the handle), so no submission can be accounted for by name");
|
|
900
|
+
if (answer.members.some((m) => !m || typeof m.handle !== "string" || !covers(m.handle) || !LEASE_STATES.has(m.state))) return { state: "UNKNOWN", why: `the lease status carries members that are not submissions under ${handle}` };
|
|
901
|
+
const rows = answer.members;
|
|
902
|
+
const aggregate = rows.some((m) => m.state === "ACTIVE") ? "ACTIVE" : rows.length > 0 && rows.every((m) => m.state === "REAPED") ? "REAPED" : "UNKNOWN";
|
|
903
|
+
if (answer.state !== aggregate) return unknown(`the answer says ${answer.state} but its ${rows.length} member row(s) aggregate to ${aggregate} (REAPED needs at least one member and every member REAPED): it contradicts itself, so it is not read`);
|
|
904
|
+
// GB-85: waitedMs is a count and waitTimedOut a boolean; both are always sent by a broker that takes a wait, and are required once a wait was asked.
|
|
905
|
+
const hasWaited = answer.waitedMs !== undefined; const hasTimedOut = answer.waitTimedOut !== undefined;
|
|
906
|
+
if (hasWaited && !(Number.isInteger(answer.waitedMs) && answer.waitedMs >= 0)) return unknown(`waitedMs is not a non-negative integer (${JSON.stringify(answer.waitedMs)})`);
|
|
907
|
+
if (hasTimedOut && typeof answer.waitTimedOut !== "boolean") return unknown(`waitTimedOut is not a boolean (${JSON.stringify(answer.waitTimedOut)})`);
|
|
908
|
+
if (waited > 0 && (!hasWaited || !hasTimedOut)) return unknown(`a wait of ${clamp(waited)} ms was asked but the answer carries no ${!hasWaited ? "waitedMs" : "waitTimedOut"}`);
|
|
909
|
+
const timedOut = answer.waitTimedOut === true;
|
|
910
|
+
if (timedOut && (waited <= 0 || answer.state === "REAPED")) return unknown(`waitTimedOut is true although ${waited <= 0 ? "no wait was asked" : "the state is REAPED"}: it contradicts itself`);
|
|
911
|
+
if (waited > 0 && answer.state !== "REAPED" && !timedOut) return unknown(`the state is ${answer.state} after a wait without waitTimedOut: it contradicts itself`);
|
|
912
|
+
const timing = { ...(hasWaited ? { waitedMs: answer.waitedMs } : {}), ...(hasTimedOut ? { waitTimedOut: answer.waitTimedOut } : {}) };
|
|
913
|
+
if (timedOut) return { state: "UNKNOWN", why: `rustright snapshot-lease status: still ${answer.state} after waiting ${answer.waitedMs} ms (waitTimedOut); ownership stays UNKNOWN`, members: rows, ...timing };
|
|
914
|
+
return { state: answer.state, why: `rustright snapshot-lease status: ${answer.state}`, members: rows, ...timing };
|
|
882
915
|
}
|
|
883
916
|
|
|
884
917
|
function publish(target, text) {
|
|
@@ -933,7 +966,7 @@ function coveredMember(m, owner, { byPid, own }) {
|
|
|
933
966
|
* Whether the work `owner` covers, other than the process `self` (the live gate at release, null at reclaim), is proven gone.
|
|
934
967
|
* Returns the proofs, or throws a reason that is busy.
|
|
935
968
|
*/
|
|
936
|
-
function coveredWorkGone(owner, { probe, lease, file, selfPid = null }) {
|
|
969
|
+
function coveredWorkGone(owner, { probe, lease, file, selfPid = null, waitMs = 0 }) {
|
|
937
970
|
const proofs = [];
|
|
938
971
|
let rebooted = false;
|
|
939
972
|
// A reboot is only a question for a dead owner; a live gate releasing its own record has not outlived one.
|
|
@@ -1024,30 +1057,72 @@ function coveredWorkGone(owner, { probe, lease, file, selfPid = null }) {
|
|
|
1024
1057
|
proofs.push(`no process has a working directory, executable, open or mapped file or argument inside or naming ${owner.snapshot}`);
|
|
1025
1058
|
}
|
|
1026
1059
|
}
|
|
1027
|
-
// macOS: a process whose environment `ps eww` does not show
|
|
1060
|
+
// macOS: a process whose environment `ps eww` does not show cannot be attributed to this ownership by the token net. Start time plus a
|
|
1061
|
+
// hidden environment is no attribution (rightkit#1313: an unrelated same-user process started during the run held every later gate), so
|
|
1062
|
+
// such a process is REPORTED here and never held. Only lineage the gate recorded (tracked child groups and intents above), the exact
|
|
1063
|
+
// ownership token, an actual snapshot user, and the broker lease hold a release.
|
|
1028
1064
|
if (typeof probe.hiddenHolders === "function") {
|
|
1029
1065
|
let hidden;
|
|
1030
|
-
try { hidden = probe.hiddenHolders({ owner, anchorMs: skipBefore, recorded }); } catch
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
throw new Error(`${others.length} process(es) may still be this ownership's work (hidden environment, held after the anchor unless its lineage clears it): ${shown.map((h) => `${h.pid}: ${h.why}`).join("; ")}${others.length > shown.length ? `; and ${others.length - shown.length} more (pids ${others.slice(shown.length).map((h) => h.pid).join(", ")})` : ""}`);
|
|
1036
|
-
}
|
|
1037
|
-
proofs.push("every live process of this user whose environment is hidden started before the anchor or descends from a process that did");
|
|
1066
|
+
try { hidden = probe.hiddenHolders({ owner, anchorMs: skipBefore, recorded }); } catch { hidden = null; }
|
|
1067
|
+
const others = (hidden ?? []).filter((h) => h.pid !== selfPid);
|
|
1068
|
+
if (others.length) {
|
|
1069
|
+
const shown = others.slice(0, 8);
|
|
1070
|
+
proofs.push(`reported, not held: ${others.length} unattributed process(es) of this user with a hidden environment started after the anchor (no recorded lineage, token or snapshot use): ${shown.map((h) => `${h.pid}: ${h.why}`).join("; ")}${others.length > shown.length ? `; and ${others.length - shown.length} more (pids ${others.slice(shown.length).map((h) => h.pid).join(", ")})` : ""}`);
|
|
1038
1071
|
}
|
|
1039
1072
|
}
|
|
1040
1073
|
} else if (!rebooted) {
|
|
1041
1074
|
throw new Error(`the record names no containment (${JSON.stringify(c)})`);
|
|
1042
1075
|
}
|
|
1043
1076
|
if (owner.broker?.submitted) {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1077
|
+
// Every submission under this handle must be accounted for by NAME, never by the bare handle (GB-21, GB-23, GB-85). The bare handle answers REAPED when at least one
|
|
1078
|
+
// member completed and none is held (rustright snapshot.rs `status`), so it proves only that no lease is held now: a completed sibling never stands in for an
|
|
1079
|
+
// unknown submission. Each recorded sub-handle (broker.handles, recorded before its send) is asked by its exact name and must be one of
|
|
1080
|
+
// - REAPED, as a member of that answer (a durable completion; `spawned: false` for a refusal that ran nothing), or
|
|
1081
|
+
// - UNSENT: the gate itself proved, under the record's lock, that its client never started (broker.unsent), AND the broker holds nothing under it (UNKNOWN, no members).
|
|
1082
|
+
// An UNKNOWN or ACTIVE one, an ambiguous send, a start that may have run, keeps the record; nothing is counted complete on the absence of a completion.
|
|
1083
|
+
const handles = [...new Set(Array.isArray(owner.broker.handles) ? owner.broker.handles : [])];
|
|
1084
|
+
const unsent = owner.broker.unsent && typeof owner.broker.unsent === "object" ? owner.broker.unsent : {};
|
|
1085
|
+
const waitOptions = waitMs > 0 ? { waitMs } : {};
|
|
1086
|
+
const settled = (sub) => unsent[sub] && typeof unsent[sub].reason === "string";
|
|
1087
|
+
const everyUnsent = handles.length > 0 && handles.every(settled);
|
|
1088
|
+
const status = lease({ snapshot: owner.snapshot, handle: owner.handle, ...waitOptions });
|
|
1089
|
+
if (status.state === "ACTIVE" || (status.state !== "REAPED" && !everyUnsent)) throw new Error(`broker work may exist under this handle and its lease is ${status.state} (${status.why})`);
|
|
1090
|
+
if (status.state === "REAPED") proofs.push(`broker lease for ${owner.handle} is REAPED`);
|
|
1091
|
+
// GB-23: the answer's own member rows account for each recorded sub-handle too: a recorded one that the broker lists and does not list REAPED keeps ownership, whatever the aggregate said.
|
|
1092
|
+
if (Array.isArray(status.members)) for (const sub of handles) { const row = status.members.find((m) => m.handle === sub); if (row && row.state !== "REAPED") throw new Error(`the broker lists sub-handle ${sub} as ${row.state} among the members of ${owner.handle}`); }
|
|
1093
|
+
let neverStarted = 0;
|
|
1094
|
+
for (const sub of handles) {
|
|
1095
|
+
if (typeof sub !== "string" || !sub.startsWith(`${owner.handle}#`) || sub.length <= owner.handle.length + 1) throw new Error(`the record lists ${JSON.stringify(sub)}, which is not a sub-handle of ${owner.handle}`);
|
|
1096
|
+
const one = lease({ snapshot: owner.snapshot, handle: sub, ...waitOptions });
|
|
1097
|
+
const member = Array.isArray(one.members) ? one.members.find((m) => m.handle === sub) : null;
|
|
1098
|
+
if (one.state === "REAPED") {
|
|
1099
|
+
if (Array.isArray(one.members) && !(member && member.state === "REAPED")) throw new Error(`the broker answers REAPED for ${sub} but lists no completed submission under that name`);
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
if (settled(sub) && one.state === "UNKNOWN" && Array.isArray(one.members) && one.members.length === 0) { proofs.push(`${sub} never started (${unsent[sub].reason}) and the broker holds nothing under it`); neverStarted += 1; continue; }
|
|
1103
|
+
throw new Error(`broker work may exist under sub-handle ${sub} and its lease is ${one.state} (${one.why})`);
|
|
1104
|
+
}
|
|
1105
|
+
if (handles.length) proofs.push(neverStarted === 0 ? `broker lease for each of ${handles.length} sub-handle(s) is REAPED` : `every one of ${handles.length} sub-handle(s) is accounted for: ${handles.length - neverStarted} REAPED, ${neverStarted} proven never started`);
|
|
1047
1106
|
}
|
|
1048
1107
|
return proofs;
|
|
1049
1108
|
}
|
|
1050
1109
|
|
|
1110
|
+
/**
|
|
1111
|
+
* Whether the claimant a standing `.reclaim` body names is `"dead"` (proven), `"live"` or `"unknown"`. The body is the claimant's own owner record, so the same identity
|
|
1112
|
+
* rules as the record's judge apply: another host or an unreadable body is unknown; a reboot since the claimant started, a gone pid, or a pid that now runs a process that
|
|
1113
|
+
* started at another time is dead; the same process still alive is live.
|
|
1114
|
+
*/
|
|
1115
|
+
export function judgeClaimant(text, { probe, host }) {
|
|
1116
|
+
let rec = null; try { rec = JSON.parse(text ?? ""); } catch { rec = null; }
|
|
1117
|
+
if (rec?.schema !== OWNERSHIP_SCHEMA || typeof rec.handle !== "string" || !Number.isInteger(rec.pid) || rec.pid <= 0 || rec.pid > 0x7fffffff || !Number.isFinite(rec.startedMs)) return "unknown";
|
|
1118
|
+
if (rec.host !== host) return "unknown";
|
|
1119
|
+
try { const boot = probe.bootTime?.(); if (Number.isFinite(boot) && rec.startedMs < boot) return "dead"; } catch { /* the pid proof below decides */ }
|
|
1120
|
+
let now; try { now = probe.identity(rec.pid); } catch { return "unknown"; }
|
|
1121
|
+
if (!now.alive) return "dead";
|
|
1122
|
+
if (!Number.isFinite(now.startedMs)) return "unknown";
|
|
1123
|
+
return sameStart(now.startedMs, rec.startedMs) ? "live" : "dead";
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1051
1126
|
/**
|
|
1052
1127
|
* Why the record `observed` may be reclaimed (`{ owner, proofs }`), or an OwnershipBusy throw naming what is still live or
|
|
1053
1128
|
* unknown. Pure apart from the probes.
|
|
@@ -1072,7 +1147,7 @@ export function judgeOwner(observed, { file, probe, lease, host = os.hostname()
|
|
|
1072
1147
|
|
|
1073
1148
|
/**
|
|
1074
1149
|
* Acquires the host's build ownership (or joins it, when RIGHTKIT_BUILD_OWNERSHIP names the current record). Returns
|
|
1075
|
-
* `{ handle, file, joined, reclaimed, owner, markBrokerSubmit(), held(), release() }`. Throws OwnershipBusy while another build
|
|
1150
|
+
* `{ handle, file, joined, reclaimed, owner, markBrokerSubmit(callHandle), held(), release() }`. Throws OwnershipBusy while another build
|
|
1076
1151
|
* owns the host, its liveness is unknown, or this gate cannot be contained.
|
|
1077
1152
|
*/
|
|
1078
1153
|
export function acquireBuildOwnership({
|
|
@@ -1109,7 +1184,7 @@ export function acquireBuildOwnership({
|
|
|
1109
1184
|
if (!live.alive || (Number.isFinite(owner.startedMs) && Number.isFinite(live.startedMs) && !sameStart(live.startedMs, owner.startedMs))) {
|
|
1110
1185
|
throw new OwnershipBusy(`build ownership ${file}: ${OWNERSHIP_ENV}=${inherited} names a record whose owner pid ${owner.pid} is no longer the process that took it; a nested gate joins only a live owner's record`);
|
|
1111
1186
|
}
|
|
1112
|
-
return { handle: inherited, file, joined: true, reclaimed: null, owner, markBrokerSubmit: () => markBrokerSubmit(file, inherited), held: () => holds(file, inherited), release: () => ({ released: false, joined: true }) };
|
|
1187
|
+
return { handle: inherited, file, joined: true, reclaimed: null, owner, markBrokerSubmit: (callHandle) => markBrokerSubmit(file, inherited, callHandle), markBrokerUnsent: (callHandle, reason) => markBrokerUnsent(file, inherited, callHandle, reason), held: () => holds(file, inherited), release: () => ({ released: false, joined: true }) };
|
|
1113
1188
|
}
|
|
1114
1189
|
throw new OwnershipBusy(`build ownership ${file}: ${OWNERSHIP_ENV}=${inherited} is not the current owner; a nested gate never takes ownership of its own`);
|
|
1115
1190
|
}
|
|
@@ -1145,12 +1220,13 @@ export function acquireBuildOwnership({
|
|
|
1145
1220
|
if (!holds(file, handle)) return { released: false, why: "no longer the owner" };
|
|
1146
1221
|
let proofs;
|
|
1147
1222
|
// The record as it is now (a broker submit may have been marked since it was written).
|
|
1148
|
-
try { proofs = coveredWorkGone(JSON.parse(readText(file)), { probe, lease, file, selfPid: process.pid }); }
|
|
1223
|
+
try { proofs = coveredWorkGone(JSON.parse(readText(file)), { probe, lease, file, selfPid: process.pid, waitMs: RELEASE_LEASE_WAIT_MS }); }
|
|
1149
1224
|
catch (e) { return { released: false, why: `left for the next gate to reclaim: ${e.message}` }; }
|
|
1150
1225
|
finally { if (exportEnv && process.env[OWNERSHIP_ENV] === handle) { delete process.env[OWNERSHIP_ENV]; delete process.env[OWNERSHIP_FILE_ENV]; } }
|
|
1151
1226
|
// Only a release that proved zero readers leaves the marker; a held one leaves none (and keeps the record).
|
|
1152
1227
|
writeCleanMarker(file, record);
|
|
1153
1228
|
fs.rmSync(file, { force: true });
|
|
1229
|
+
fs.rmSync(markLockFile(file), { force: true });
|
|
1154
1230
|
fs.rmSync(childrenDir(file, handle), { recursive: true, force: true });
|
|
1155
1231
|
return { released: true, proofs };
|
|
1156
1232
|
};
|
|
@@ -1173,7 +1249,7 @@ export function acquireBuildOwnership({
|
|
|
1173
1249
|
catch (e) { if (holds(file, handle)) fs.rmSync(file, { force: true }); throw new OwnershipBusy(`build ownership ${file}: the containment could not be recorded (${e.code ?? e.message}: ${e.message})`); }
|
|
1174
1250
|
}
|
|
1175
1251
|
if (exportEnv) { process.env[OWNERSHIP_ENV] = handle; process.env[OWNERSHIP_FILE_ENV] = file; }
|
|
1176
|
-
return { handle, file, joined: false, reclaimed, owner: record, contained: platform === "win32" ? record.containment.contained === true : true, markBrokerSubmit: () => markBrokerSubmit(file, handle), held: () => holds(file, handle), release };
|
|
1252
|
+
return { handle, file, joined: false, reclaimed, owner: record, contained: platform === "win32" ? record.containment.contained === true : true, markBrokerSubmit: (callHandle) => markBrokerSubmit(file, handle, callHandle), markBrokerUnsent: (callHandle, reason) => markBrokerUnsent(file, handle, callHandle, reason), held: () => holds(file, handle), release };
|
|
1177
1253
|
};
|
|
1178
1254
|
if (publish(file, body)) return done(null);
|
|
1179
1255
|
const observed = readText(file);
|
|
@@ -1185,16 +1261,27 @@ export function acquireBuildOwnership({
|
|
|
1185
1261
|
record.freeHost = false;
|
|
1186
1262
|
const reclaimBody = JSON.stringify(record);
|
|
1187
1263
|
const reclaim = `${file}.reclaim`;
|
|
1188
|
-
|
|
1264
|
+
// GB-56: a `.reclaim` already there is judged by the identity its body carries (the claimant's owner record: pid, start, host, boot), never taken for a live claimant on its
|
|
1265
|
+
// existence alone. A claimant PROVEN dead (its pid is gone, the pid now runs a process that started at another time, or the host rebooted since) is removed, compare-by-content,
|
|
1266
|
+
// and the claim retried once; a live, unreadable or unprovable claimant keeps the lock. Bounded: one retry, so two reclaimers never loop.
|
|
1267
|
+
if (!publish(reclaim, reclaimBody)) {
|
|
1268
|
+
const standing = readText(reclaim);
|
|
1269
|
+
const verdict = judgeClaimant(standing, { probe, host: os.hostname() });
|
|
1270
|
+
if (verdict === "dead" && readText(reclaim) === standing) { fs.rmSync(reclaim, { force: true }); }
|
|
1271
|
+
if (verdict !== "dead" || !publish(reclaim, reclaimBody)) throw new OwnershipBusy(`build ownership ${file} is being reclaimed by another process (${verdict === "dead" ? "its claim was dropped but another gate took it first" : verdict === "live" ? "a live gate holds the reclaim claim" : "the reclaim claim cannot be proven dead"}: remove ${reclaim} only if no gate is running)`);
|
|
1272
|
+
}
|
|
1189
1273
|
try {
|
|
1274
|
+
hooks.afterClaim?.(reclaim);
|
|
1190
1275
|
if (readText(file) !== observed) throw new OwnershipBusy(`build ownership ${file} changed before it could be reclaimed`);
|
|
1191
1276
|
fs.rmSync(file, { force: true });
|
|
1277
|
+
fs.rmSync(markLockFile(file), { force: true });
|
|
1192
1278
|
fs.rmSync(holderFile(file, judged.owner.handle), { force: true });
|
|
1193
1279
|
fs.rmSync(holderScriptFile(holderFile(file, judged.owner.handle)), { force: true });
|
|
1194
1280
|
fs.rmSync(childrenDir(file, judged.owner.handle), { recursive: true, force: true });
|
|
1195
1281
|
if (!publish(file, reclaimBody)) throw new OwnershipBusy(`build ownership ${file} was taken by another process`);
|
|
1196
1282
|
} finally {
|
|
1197
|
-
|
|
1283
|
+
// Token-checked: only this reclaim's own claim is removed (a successor's claim, published after this one was judged dead, stays).
|
|
1284
|
+
if (readText(reclaim) === reclaimBody) fs.rmSync(reclaim, { force: true });
|
|
1198
1285
|
}
|
|
1199
1286
|
return done({ from: judged.owner.handle, pid: judged.owner.pid, proofs: judged.proofs });
|
|
1200
1287
|
}
|
|
@@ -1209,23 +1296,4 @@ function rewriteRecord(file, handle, change) {
|
|
|
1209
1296
|
try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
|
|
1210
1297
|
}
|
|
1211
1298
|
|
|
1212
|
-
function holds(file, handle) {
|
|
1213
|
-
try { return JSON.parse(fs.readFileSync(file, "utf8")).handle === handle; } catch { return false; }
|
|
1214
|
-
}
|
|
1215
1299
|
|
|
1216
|
-
/**
|
|
1217
|
-
* Records, before the first broker submit, that broker work may exist under this handle; from then on the record is reclaimed
|
|
1218
|
-
* only on the broker's REAPED. Replaces the record atomically and only while it is still this handle's.
|
|
1219
|
-
*/
|
|
1220
|
-
function markBrokerSubmit(file, handle) {
|
|
1221
|
-
const text = readText(file);
|
|
1222
|
-
let owner = null; try { owner = JSON.parse(text ?? ""); } catch { owner = null; }
|
|
1223
|
-
if (owner?.handle !== handle) throw new OwnershipBusy(`build ownership ${file} is no longer held by ${handle}; no broker request may be submitted`);
|
|
1224
|
-
if (owner.broker?.submitted) return owner;
|
|
1225
|
-
const next = { ...owner, broker: { ...(owner.broker ?? {}), submitted: true, at: new Date().toISOString() } };
|
|
1226
|
-
const tmp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
1227
|
-
fs.writeFileSync(tmp, JSON.stringify(next), { flag: "wx", mode: 0o600 });
|
|
1228
|
-
try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
|
|
1229
|
-
if (!holds(file, handle)) throw new OwnershipBusy(`build ownership ${file} changed while recording a broker submit`);
|
|
1230
|
-
return next;
|
|
1231
|
-
}
|
package/build-release.mjs
CHANGED
|
@@ -55,6 +55,7 @@ import { ANDROID_RECEIPT_NAME, androidSealEntry, validateAndroidTarget, verifyAn
|
|
|
55
55
|
import { IOS_RECEIPT_NAME, iosSealEntry, verifyIosReceipt } from "./ios-package-recipe.mjs";
|
|
56
56
|
import { expandIosPackageTarget } from "./package-recipe-targets.mjs";
|
|
57
57
|
import { assertUpdaterKeyId, assertUpdaterSignature, isSuiteUpdaterApp, resolveUpdaterTrust } from "./github-updater-release.mjs";
|
|
58
|
+
import { platformDirOf } from "./platform-dir.mjs";
|
|
58
59
|
|
|
59
60
|
// The iOS lane's own packaging code, kept out of the win/mac fingerprint for the same reason as the Android one.
|
|
60
61
|
const IOS_PIPELINE_FINGERPRINT = createHash("sha256")
|
|
@@ -266,7 +267,7 @@ try {
|
|
|
266
267
|
: collectToolVersions(config.packageManager ?? "pnpm", appRoot);
|
|
267
268
|
const inputHashes = hashInputs(requiredInputs);
|
|
268
269
|
const releaseId = `${config.app}-${config.version}-${shortCommit}`;
|
|
269
|
-
const platformDir = platform
|
|
270
|
+
const platformDir = platformDirOf(platform);
|
|
270
271
|
const stateRoot = path.join(vaultRoot, "state", releaseId, platformDir);
|
|
271
272
|
const signingIdentity = platform === "win" ? {
|
|
272
273
|
contract: target.signingContract ?? "<missing>",
|
|
@@ -611,7 +612,7 @@ async function admit() {
|
|
|
611
612
|
throw error;
|
|
612
613
|
}
|
|
613
614
|
// A fresh build asks the registries once, here; a resume or a re-run over a sealed release reuses its recorded snapshot instead.
|
|
614
|
-
const platformName = platform
|
|
615
|
+
const platformName = platformDirOf(platform);
|
|
615
616
|
const sealed = config?.app && config?.version
|
|
616
617
|
&& existsSync(path.join(repoRoot, ".right-release", "sealed", `${config.app}-${config.version}-${commit.slice(0, 8)}`, platformName, "release-manifest.json"));
|
|
617
618
|
const reuse = Boolean(resumeFrom) || Boolean(sealed);
|
package/cache-command.mjs
CHANGED
|
@@ -30,13 +30,18 @@ export async function runCacheCommand(args = process.argv.slice(2), { env = proc
|
|
|
30
30
|
const [command, ...rest] = args;
|
|
31
31
|
const json = rest.includes("--json");
|
|
32
32
|
const platform = platformFrom(env);
|
|
33
|
-
|
|
33
|
+
// GB-65: the broker-owned route is decided BEFORE the Cache V2 root is resolved. A broker Mac sets no RIGHT_RELEASE_CACHE_ROOT (the broker
|
|
34
|
+
// owns the target), and resolving the unused root first threw for all three commands before the intended skip. Arguments are still
|
|
35
|
+
// validated, and nothing is inspected, migrated or pruned on this route.
|
|
34
36
|
if (BROKER_GATED_COMMANDS.has(command) && isBrokerHost(env)) {
|
|
35
|
-
|
|
37
|
+
rejectUnknown(rest.filter((value, i) => !(command === "migrate" && (value === "--config" || rest[i - 1] === "--config"))), new Set(command === "status" ? ["--json"] : ["--json", "--dry-run", "--apply"]));
|
|
38
|
+
if (command !== "status" && rest.includes("--dry-run") && rest.includes("--apply")) throw new Error(`cache ${command} accepts either --dry-run or --apply`);
|
|
39
|
+
const result = { schema: 1, command, cacheRoot: null, brokerManaged: true, skipped: true, message: BROKER_MANAGED_MESSAGE };
|
|
36
40
|
if (json) stdout(JSON.stringify(result));
|
|
37
41
|
else stdout(BROKER_MANAGED_MESSAGE);
|
|
38
42
|
return result;
|
|
39
43
|
}
|
|
44
|
+
const cacheRoot = resolveSharedCacheRoot({ platform, env });
|
|
40
45
|
if (command === "status") {
|
|
41
46
|
rejectUnknown(rest, new Set(["--json"]));
|
|
42
47
|
const snapshot = inspectCache({ cacheRoot });
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// GB-73 (BROKER-CONTRACT section 16): the candidate build receipt the gate writes from its SNAPSHOT build, which RustRight's installer takes instead of a clean primary checkout:
|
|
2
|
+
// rustright-install stage|cutover ... --candidate-receipt <file> --candidate-receipt-sha256 <digest>
|
|
3
|
+
// where the file is exactly
|
|
4
|
+
// { "schema": "rustright.candidate-build-receipt.v1", "commit", "tree", "source": "snapshot", "artifacts": { "broker", "client" } }
|
|
5
|
+
// (full lowercase commit and tree ids, the sha256 of the broker and client binaries being installed). The installer accepts it only when the file hashes to the DIGEST THE GATE RECORDED (the
|
|
6
|
+
// digest comes from the gate's record, never from the file), the commit is the asked-for one, the tree is that commit's own tree in the checkout's object database, the artifact digests are the
|
|
7
|
+
// ones being installed and the commit is on origin/main; a tampered receipt, a wrong tree, other binaries and an off-origin commit each refuse before effects. So this module writes the receipt
|
|
8
|
+
// deterministically, records its digest apart from it, hands the installer its two arguments, and verifies a receipt the way the installer does before the gate hands it on.
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { writeFileAtomic } from "./atomic-write.mjs";
|
|
13
|
+
|
|
14
|
+
export const CANDIDATE_RECEIPT_SCHEMA = "rustright.candidate-build-receipt.v1";
|
|
15
|
+
export const CANDIDATE_RECORD_SCHEMA = "rightkit.candidate-receipt-record/1";
|
|
16
|
+
export const CANDIDATE_RECEIPT_FILE = "candidate-build-receipt.json";
|
|
17
|
+
export const CANDIDATE_RECORD_FILE = "candidate-build-receipt.record.json";
|
|
18
|
+
const OBJECT_ID = /^[0-9a-f]{40}$/;
|
|
19
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
20
|
+
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
|
21
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
22
|
+
|
|
23
|
+
/** The receipt object for a snapshot build of `commit` (whose tree is `tree`): exactly RustRight's fields, in a fixed order. Refuses anything that is not a full lowercase id or digest. */
|
|
24
|
+
export function buildCandidateReceipt({ commit, tree, brokerSha256, clientSha256 }) {
|
|
25
|
+
const problems = [];
|
|
26
|
+
if (!OBJECT_ID.test(commit ?? "")) problems.push("commit must be a full lowercase object id");
|
|
27
|
+
if (!OBJECT_ID.test(tree ?? "")) problems.push("tree must be a full lowercase object id");
|
|
28
|
+
if (!DIGEST.test(brokerSha256 ?? "")) problems.push("the broker artifact digest must be 64 lowercase hex characters");
|
|
29
|
+
if (!DIGEST.test(clientSha256 ?? "")) problems.push("the client artifact digest must be 64 lowercase hex characters");
|
|
30
|
+
if (problems.length) throw Object.assign(new Error(`the candidate build receipt cannot be written: ${problems.join("; ")}`), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
|
|
31
|
+
return { schema: CANDIDATE_RECEIPT_SCHEMA, commit, tree, source: "snapshot", artifacts: { broker: brokerSha256, client: clientSha256 } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The bytes the receipt is written as and hashed over: its JSON, one line, a final newline. */
|
|
35
|
+
export const candidateReceiptBytes = (receipt) => Buffer.from(`${JSON.stringify(receipt)}\n`, "utf8");
|
|
36
|
+
|
|
37
|
+
/** The digest of one built artifact (a regular file), or a typed refusal. */
|
|
38
|
+
export function artifactDigest(file, { readFile = fs.readFileSync, stat = fs.statSync } = {}) {
|
|
39
|
+
if (typeof file !== "string" || !path.isAbsolute(file)) throw Object.assign(new Error(`the artifact ${JSON.stringify(file)} must be an absolute path`), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
|
|
40
|
+
let meta; try { meta = stat(file); } catch (e) { throw Object.assign(new Error(`the artifact ${file} cannot be read (${e.code ?? e.message})`), { code: "RK_CANDIDATE_RECEIPT_INVALID" }); }
|
|
41
|
+
if (!meta.isFile()) throw Object.assign(new Error(`the artifact ${file} is not a regular file`), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
|
|
42
|
+
return sha256(readFile(file));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Writes the receipt and, apart from it, the gate's record of its digest, into `dir` (created, owner-only, each written atomically). Returns the record: `{ schema, file, sha256, commit, tree,
|
|
47
|
+
* artifacts, recordFile }`. A directory that already holds a receipt for other bytes is refused: a candidate's receipt is written once.
|
|
48
|
+
*/
|
|
49
|
+
export function writeCandidateReceipt({ dir, receipt, now = Date.now(), write = writeFileAtomic, read = (f) => fs.readFileSync(f), exists = fs.existsSync }) {
|
|
50
|
+
const file = path.join(dir, CANDIDATE_RECEIPT_FILE); const recordFile = path.join(dir, CANDIDATE_RECORD_FILE);
|
|
51
|
+
const bytes = candidateReceiptBytes(receipt);
|
|
52
|
+
if (exists(file) && sha256(read(file)) !== sha256(bytes)) throw Object.assign(new Error(`${file} already holds a different candidate build receipt; a candidate's receipt is written once`), { code: "RK_CANDIDATE_RECEIPT_EXISTS" });
|
|
53
|
+
write(file, bytes, { mode: 0o600 });
|
|
54
|
+
const record = { schema: CANDIDATE_RECORD_SCHEMA, file, sha256: sha256(bytes), commit: receipt.commit, tree: receipt.tree, artifacts: { ...receipt.artifacts }, recordedAt: new Date(now).toISOString() };
|
|
55
|
+
write(recordFile, Buffer.from(`${JSON.stringify(record)}\n`, "utf8"), { mode: 0o600 });
|
|
56
|
+
return { ...record, recordFile };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The installer's two arguments for a receipt record: the file and the digest the GATE recorded. */
|
|
60
|
+
export function candidateReceiptArgs(record) {
|
|
61
|
+
if (!record || typeof record.file !== "string" || !DIGEST.test(record.sha256 ?? "")) throw Object.assign(new Error("a candidate receipt record names its file and its sha256"), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
|
|
62
|
+
return ["--candidate-receipt", record.file, "--candidate-receipt-sha256", record.sha256];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether the receipt at `file` is acceptable for installing `commit` / `tree` with the binaries whose digests are `brokerSha256` and `clientSha256`, judged as RustRight's installer judges it
|
|
67
|
+
* (provenance.rs): the file hashes to `expectedSha256` (the gate's recorded digest, never a value read from the file), it is exactly {schema, commit, tree, source, artifacts:{broker, client}} with the
|
|
68
|
+
* v1 schema and source "snapshot", the commit and tree are the asked-for ones and the artifact digests are the ones being installed. Returns the problems; none means acceptable. (That the commit is on
|
|
69
|
+
* origin/main and the tree is the commit's own is the installer's check, in the checkout's object database.)
|
|
70
|
+
*/
|
|
71
|
+
export function candidateReceiptProblems({ file, expectedSha256, commit, tree, brokerSha256, clientSha256, readFile = fs.readFileSync }) {
|
|
72
|
+
let bytes; try { bytes = readFile(file); } catch (e) { return [`the receipt ${file} cannot be read (${e.code ?? e.message})`]; }
|
|
73
|
+
const problems = [];
|
|
74
|
+
if (!DIGEST.test(expectedSha256 ?? "")) return ["the gate's recorded digest is missing or malformed"];
|
|
75
|
+
if (sha256(bytes) !== expectedSha256) problems.push(`the file hashes to ${sha256(bytes)}, not the digest the gate recorded (${expectedSha256}): it was changed after it was written`);
|
|
76
|
+
let doc; try { doc = JSON.parse(Buffer.from(bytes).toString("utf8")); } catch { return [...problems, "the receipt is not JSON"]; }
|
|
77
|
+
if (!isObject(doc) || Object.keys(doc).sort().join() !== "artifacts,commit,schema,source,tree") problems.push("the receipt is not exactly {schema, commit, tree, source, artifacts}");
|
|
78
|
+
if (doc?.schema !== CANDIDATE_RECEIPT_SCHEMA) problems.push(`the schema is ${JSON.stringify(doc?.schema)}, not ${CANDIDATE_RECEIPT_SCHEMA}`);
|
|
79
|
+
if (doc?.source !== "snapshot") problems.push(`the source is ${JSON.stringify(doc?.source)}, not "snapshot"`);
|
|
80
|
+
if (doc?.commit !== commit) problems.push(`the receipt is for commit ${String(doc?.commit).slice(0, 12)}, not ${String(commit).slice(0, 12)}`);
|
|
81
|
+
if (doc?.tree !== tree) problems.push(`the receipt names tree ${String(doc?.tree).slice(0, 12)}, not the commit's ${String(tree).slice(0, 12)}`);
|
|
82
|
+
const artifacts = doc?.artifacts;
|
|
83
|
+
if (!isObject(artifacts) || Object.keys(artifacts).sort().join() !== "broker,client") problems.push("artifacts is not exactly {broker, client}");
|
|
84
|
+
else {
|
|
85
|
+
if (artifacts.broker !== brokerSha256) problems.push("the broker binary being installed is not the one the receipt names");
|
|
86
|
+
if (artifacts.client !== clientSha256) problems.push("the client binary being installed is not the one the receipt names");
|
|
87
|
+
}
|
|
88
|
+
return problems;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The gate's record read back (never the receipt's own digest): `{ record }` or `{ problem }`. */
|
|
92
|
+
export function readCandidateRecord(recordFile, { readFile = fs.readFileSync } = {}) {
|
|
93
|
+
let doc; try { doc = JSON.parse(readFile(recordFile, "utf8")); } catch (e) { return { problem: `the candidate receipt record ${recordFile} cannot be read (${e.code ?? e.message})` }; }
|
|
94
|
+
if (doc?.schema !== CANDIDATE_RECORD_SCHEMA || typeof doc.file !== "string" || !DIGEST.test(doc.sha256 ?? "") || !OBJECT_ID.test(doc.commit ?? "") || !OBJECT_ID.test(doc.tree ?? "")) return { problem: `${recordFile} is not a ${CANDIDATE_RECORD_SCHEMA}` };
|
|
95
|
+
return { record: doc };
|
|
96
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// `rightkit-gate candidate-receipt --commit <full sha> --broker <file> --client <file> [--out <dir>]` (GB-73): writes the candidate build receipt (candidate-build-receipt.mjs) for the
|
|
2
|
+
// binaries a gate SNAPSHOT build of that commit produced, records its digest apart from it, and prints one JSON line with the record and the installer's two arguments
|
|
3
|
+
// (`--candidate-receipt <file> --candidate-receipt-sha256 <digest>`). The commit's tree is read from the repository the gate runs in; the artifact digests are computed here, never taken from a flag.
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { gateRunsRoot } from "./managed-roots.mjs";
|
|
6
|
+
import { artifactDigest, buildCandidateReceipt, candidateReceiptArgs, writeCandidateReceipt } from "./candidate-build-receipt.mjs";
|
|
7
|
+
|
|
8
|
+
export const CANDIDATE_RECEIPT_USAGE = "usage: rightkit-gate candidate-receipt --commit <full lowercase sha> --broker <broker binary> --client <client binary> [--out <dir>] (the binaries a gate snapshot build of that commit produced)";
|
|
9
|
+
|
|
10
|
+
export async function candidateReceiptCommand(o, args, { artifact = artifactDigest, write = writeCandidateReceipt } = {}) {
|
|
11
|
+
const take = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
|
|
12
|
+
const known = new Set(["--commit", "--broker", "--client", "--out"]);
|
|
13
|
+
const stray = args.filter((a, i) => (a.startsWith("--") ? !known.has(a) : !known.has(args[i - 1] ?? "")));
|
|
14
|
+
const commit = take("--commit"); const broker = take("--broker"); const client = take("--client");
|
|
15
|
+
if (stray.length || !commit || !broker || !client) { o.err(`rightkit-gate: ${CANDIDATE_RECEIPT_USAGE}`); return 2; }
|
|
16
|
+
const fail = (e) => { o.err(`rightkit-gate: ${e.code ?? "RK_CANDIDATE_RECEIPT_INVALID"}: ${e.message}`); return 1; };
|
|
17
|
+
let record;
|
|
18
|
+
try {
|
|
19
|
+
const resolved = o.spawn("git", ["rev-parse", "--verify", "--quiet", `${commit}^{tree}`], { cwd: o.cwd, encoding: "utf8", env: o.env });
|
|
20
|
+
const tree = String(resolved?.stdout ?? "").trim();
|
|
21
|
+
if (resolved?.status !== 0 || !/^[0-9a-f]{40}$/.test(tree)) throw Object.assign(new Error(`the commit ${commit} is not in this repository, so its tree cannot be read`), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
|
|
22
|
+
const receipt = buildCandidateReceipt({ commit, tree, brokerSha256: artifact(broker), clientSha256: artifact(client) });
|
|
23
|
+
const out = take("--out") ?? path.join(gateRunsRoot({ env: o.env, platform: o.platform, home: o.env.HOME || o.env.USERPROFILE || undefined }), "candidate-receipts", commit);
|
|
24
|
+
if (!path.isAbsolute(out)) throw Object.assign(new Error(`--out ${out} must be an absolute directory`), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
|
|
25
|
+
record = write({ dir: out, receipt });
|
|
26
|
+
} catch (e) { return fail(e); }
|
|
27
|
+
o.out(JSON.stringify({ ...record, installerArgs: candidateReceiptArgs(record) }));
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
package/cargo-contract.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "./background-process.mjs";
|
|
5
|
-
import { directPlatform, managedRuntimeClient, runCargoSync } from "./managed-cargo.mjs";
|
|
5
|
+
import { directPlatform, linuxBrokerDeclared, managedRuntimeClient, runCargoSync } from "./managed-cargo.mjs";
|
|
6
6
|
|
|
7
7
|
const SKIP_DIRECTORIES = new Set([".audit", ".cache", ".git", ".claude", ".right-release", ".worktrees", "node_modules", "target", "vendor"]);
|
|
8
8
|
const CRATES_IO_SOURCES = new Set([
|
|
@@ -30,8 +30,9 @@ export function managedCargoShimOnPath(env = process.env, platform = process.pla
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function brokerManagedHost(env = process.env, platform = process.platform, exists = existsSync) {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// A Linux host is broker-managed only through its explicit declared route (managed-cargo linuxBrokerDeclared), whatever socket or control root the env names.
|
|
34
|
+
if (directPlatform(platform)) return linuxBrokerDeclared(env, platform);
|
|
35
|
+
return Boolean(managedRuntimeClient(env, platform, exists)) || Boolean(env.RUSTRIGHT_BUILD_BROKER_SOCKET || env.RIGHTKIT_BUILD_BROKER_SOCKET) || managedCargoShimOnPath(env, platform, exists);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export function isolatedCargoMetadataEnv(cargoHome, env = process.env, platform = process.platform, exists = existsSync) {
|
package/cargo-ledger.mjs
CHANGED
|
@@ -48,7 +48,7 @@ export function readLedger(dir, token) {
|
|
|
48
48
|
const entries = [...byId.values()];
|
|
49
49
|
for (const e of entries) {
|
|
50
50
|
if (OK_STATUSES.includes(e.status)) continue;
|
|
51
|
-
const call =
|
|
51
|
+
const call = `${e.adapter ?? "cargo"} ${(e.argv ?? []).join(" ")}`.trim();
|
|
52
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
53
|
}
|
|
54
54
|
return { entries, problems };
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// legal: { cargoNotices: { output: "legal/THIRD_PARTY_CARGO_LICENSES.html", template: "legal/about.hbs",
|
|
7
7
|
// config?: "legal/about.toml", manifestPath?: "src-tauri/Cargo.toml", features?: ["a"], allFeatures?: true } }
|
|
8
8
|
//
|
|
9
|
-
// `output`, `template`, `config` and `manifestPath` are app-relative and may not leave the app. The run is `cargo about generate --
|
|
9
|
+
// `output`, `template`, `config` and `manifestPath` are app-relative and may not leave the app. The run is `cargo about generate --frozen
|
|
10
10
|
// --fail [--config <config>] [--manifest-path <path>] [--all-features | --features a,b] <template>`; cargo-about writes the rendered
|
|
11
11
|
// appendix to stdout, which is normalised to LF and one trailing newline so the same lockfile always produces the same bytes. `--fail`
|
|
12
12
|
// makes an unaccepted license or an unresolved clarification an error instead of a warning. `--check` writes nothing and exits 1 when
|
|
@@ -43,7 +43,7 @@ export function resolveCargoNoticeOptions(config, flags = {}) {
|
|
|
43
43
|
/** The `cargo about` argument list (after the managed client adds `cargo`). */
|
|
44
44
|
export function cargoAboutArgs(options) {
|
|
45
45
|
return [
|
|
46
|
-
"about", "generate", "--
|
|
46
|
+
"about", "generate", "--frozen", "--fail",
|
|
47
47
|
...(options.config ? ["--config", options.config] : []),
|
|
48
48
|
...(options.manifestPath ? ["--manifest-path", options.manifestPath] : []),
|
|
49
49
|
...(options.allFeatures ? ["--all-features"] : options.features.length ? ["--features", options.features.join(",")] : []),
|