@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.
Files changed (111) hide show
  1. package/README.md +49 -1
  2. package/adapter-env.mjs +165 -0
  3. package/android-ndk.mjs +5 -1
  4. package/android-release.mjs +73 -8
  5. package/app-check.mjs +22 -8
  6. package/app-closure.mjs +214 -0
  7. package/assemble-app-command.mjs +24 -7
  8. package/atomic-write.mjs +57 -0
  9. package/bin/rightkit-gate.mjs +1193 -337
  10. package/broker-client.mjs +106 -0
  11. package/broker-exception.mjs +211 -0
  12. package/broker-handshake.mjs +160 -0
  13. package/broker-pins.mjs +27 -8
  14. package/broker-wire.mjs +262 -0
  15. package/build-launch.mjs +364 -12
  16. package/build-ownership.mjs +117 -49
  17. package/build-release.mjs +3 -2
  18. package/cache-command.mjs +7 -2
  19. package/candidate-build-receipt.mjs +96 -0
  20. package/candidate-receipt-command.mjs +29 -0
  21. package/cargo-contract.mjs +4 -3
  22. package/cargo-ledger.mjs +1 -1
  23. package/cargo-license-notices.mjs +2 -2
  24. package/cargo-shapes.mjs +332 -0
  25. package/cargo-target-shim.mjs +44 -5
  26. package/cargo-target.mjs +5 -0
  27. package/carried-adapter-run.mjs +31 -0
  28. package/carried-cargo-run.mjs +9 -0
  29. package/carried-cargo.mjs +237 -0
  30. package/carrier-census.mjs +124 -0
  31. package/class-a-launch-guard.mjs +266 -33
  32. package/cli/right-release.mjs +2 -0
  33. package/clippy-route.mjs +23 -11
  34. package/compile-inputs.mjs +9 -12
  35. package/control-plane.mjs +18 -0
  36. package/deps-cargo-lock.mjs +208 -0
  37. package/deps.mjs +5 -0
  38. package/deterministic-scans.mjs +273 -44
  39. package/development-signing.mjs +14 -1
  40. package/development.mjs +112 -26
  41. package/direct-origin.mjs +107 -0
  42. package/disk-command.mjs +1 -0
  43. package/disk-policy.mjs +18 -3
  44. package/freshness-census.mjs +206 -58
  45. package/gate-executor.mjs +30 -12
  46. package/gate-stages.mjs +93 -16
  47. package/github-release.mjs +2 -1
  48. package/github-upload-gate.mjs +2 -1
  49. package/install-tauri-authenticode.mjs +346 -0
  50. package/ios-dev.mjs +77 -23
  51. package/ios-package-recipe.mjs +100 -41
  52. package/linux-artifacts.mjs +17 -2
  53. package/linux-qualifier.mjs +83 -0
  54. package/local-publish.mjs +36 -14
  55. package/local-runtime.mjs +2 -2
  56. package/lock-identity.mjs +138 -0
  57. package/lsclean.sh +0 -0
  58. package/managed-cargo.mjs +161 -44
  59. package/managed-roots.mjs +130 -12
  60. package/media-sidecar-catalog.mjs +1 -1
  61. package/native-adapters.mjs +411 -0
  62. package/native-helpers.mjs +26 -18
  63. package/native-tool-planning.mjs +94 -0
  64. package/ownership-mark.mjs +182 -0
  65. package/pack-digest.mjs +122 -0
  66. package/package.json +12 -12
  67. package/pipeline-receipts.mjs +302 -302
  68. package/platform-dir.mjs +14 -0
  69. package/pnpm-adapter-grammar.mjs +226 -0
  70. package/progress-control.mjs +7 -2
  71. package/provenance-real-receipt.pins.json +7 -0
  72. package/provenance-verify.mjs +262 -1
  73. package/publication-outcomes.mjs +21 -0
  74. package/publish-update.mjs +2 -1
  75. package/qa-journeys-prebuilt-run.mjs +18 -0
  76. package/qa-journeys-prebuilt.mjs +148 -0
  77. package/qa-snapshot-run.mjs +53 -0
  78. package/qa-snapshot.mjs +164 -0
  79. package/qualification-verify.mjs +144 -0
  80. package/qualify-reuse.mjs +38 -0
  81. package/recipe-build.mjs +16 -3
  82. package/release-qa-audit.mjs +2 -2
  83. package/release-qa-command.mjs +2 -1
  84. package/release-qa-receipt.mjs +3 -2
  85. package/release-state.mjs +3 -2
  86. package/release.mjs +14 -3
  87. package/rightkit-drift.mjs +27 -5
  88. package/rightkit-versions.json +15 -11
  89. package/rustright-names.mjs +32 -0
  90. package/scan-tool-enrolment.mjs +167 -0
  91. package/sign-windows.mjs +212 -112
  92. package/signing-files.mjs +35 -0
  93. package/signing-identity.mjs +7 -0
  94. package/snapshot-broker.mjs +528 -57
  95. package/snapshot-check.mjs +95 -11
  96. package/snapshot-metadata.mjs +123 -0
  97. package/snapshot-request.mjs +389 -96
  98. package/stage-binary.mjs +22 -4
  99. package/stage-commands.mjs +56 -13
  100. package/stage-sidecar.mjs +93 -21
  101. package/suite-signing-policy.mjs +657 -0
  102. package/suite-signing.mjs +223 -0
  103. package/swift-scratch.mjs +25 -0
  104. package/tauri-locked.mjs +25 -0
  105. package/test-command.mjs +164 -29
  106. package/unit-gate.mjs +201 -31
  107. package/upload-large.mjs +0 -0
  108. package/web-build.mjs +2 -2
  109. package/win-package-recipe.mjs +26 -2
  110. package/bin/darwin/rightkit-swap +0 -0
  111. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -0,0 +1,94 @@
1
+ // GB-51 (GATE Mac/Dell broker-only; commit output identity): the native build tools an app config declares (xcodegen and xcodebuild for iOS, Gradle for Android, CMake for a sidecar)
2
+ // each have a constrained broker adapter (native-adapters.mjs: capability `adapter:rightkit-xcodegen|xcodebuild|gradle|cmake`) and a launch route onto it (build-launch.mjs). This module decides at PLANNING, from the
3
+ // commit's own config, whether a stage that uses them can run, so the route (and `--route`) refuses before anything builds, naming the missing capability or the unsupported command:
4
+ // - a tool whose adapter capability the broker does not advertise (or whose capability query failed) is refused naming the missing capability (one of the four above);
5
+ // - a declared build step (a dev target's `build` list) whose argv the tool's registration would refuse (an output override, an unregistered flag, a release task, a signing identity) is
6
+ // refused naming the capability and the reason: an unsupported custom command never reaches the broker;
7
+ // - a Gradle step needs the committed wrapper's distribution pinned (`distributionSha256Sum`), the adapter's pinned executable identity.
8
+ // There is no direct-session exemption: a tool leaves the refusal only when its adapter is advertised.
9
+ import path from "node:path";
10
+ import { NATIVE_ADAPTERS, gradleWrapperPinProblem, nativeToolLaunch } from "./native-adapters.mjs";
11
+ import { validateAndroidNdk } from "./android-ndk.mjs";
12
+
13
+ /**
14
+ * GB-62: `cargo ndk` is a pinned EXTERNAL tool: RustRight's `externalCommands` entry (shim cargo, command ndk, a toolId and the tool's digest; policy BUILD). The broker advertises it
15
+ * as this capability; a stage that builds JNI libraries is planned only when it is advertised.
16
+ */
17
+ export const NDK_EXTERNAL_CAPABILITY = "external:cargo-ndk";
18
+ /** The ABIs RustRight's ndk grammar accepts (`parse_ndk_argv`: arm64-v8a, x86_64); the gate's own validator also knows armeabi-v7a and x86, which the broker would refuse. */
19
+ export const BROKER_NDK_ABIS = Object.freeze(["arm64-v8a", "x86_64"]);
20
+
21
+ /** The tools that run through a constrained adapter, by the capability name a refusal reports (xcodegen, xcodebuild, gradle and its wrapper, cmake). */
22
+ export const NATIVE_TOOLS_WITHOUT_ADAPTER = Object.freeze({ xcodegen: NATIVE_ADAPTERS.xcodegen.capability, xcodebuild: NATIVE_ADAPTERS.xcodebuild.capability, gradle: NATIVE_ADAPTERS.gradle.capability, gradlew: NATIVE_ADAPTERS.gradle.capability, cmake: NATIVE_ADAPTERS.cmake.capability });
23
+
24
+ const base = (command) => path.basename(String(command ?? "").replaceAll("\\", "/")).replace(/\.(exe|cmd|bat)$/i, "").toLowerCase();
25
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
26
+
27
+ /**
28
+ * The native-tool uses of a stage, `[{ tool, capability, where }]`, from the commit's own config. `devPlatform` is the dev target's platform for a dev or
29
+ * test run (its declared build steps and, for iOS, the xcodegen/xcodebuild steps the gate itself generates are in play); a `cmake` sidecar is in play for
30
+ * every stage that stages sidecars.
31
+ */
32
+ export function nativeToolUses({ config, to, devPlatform = null, platform = null }) {
33
+ const uses = [];
34
+ const add = (command, where, args = null, cwd = ".") => { const tool = base(command); if (Object.hasOwn(NATIVE_TOOLS_WITHOUT_ADAPTER, tool)) uses.push({ tool, capability: NATIVE_TOOLS_WITHOUT_ADAPTER[tool], where, ...(args ? { args, cwd } : {}) }); };
35
+ if (!isObject(config)) return uses;
36
+ const addNdk = (ndk, where, kind) => { if (ndk !== undefined) uses.push({ tool: "cargo-ndk", capability: NDK_EXTERNAL_CAPABILITY, where, ndk, kind }); };
37
+ if ((to === "dev" || to === "test") && (platform ?? devPlatform) === "android") addNdk(config.development?.targets?.android?.ndk, "development.targets.android.ndk", "dev");
38
+ if ((to === "package" || to === "release") && (platform ?? devPlatform) === "android") addNdk(config.targets?.android?.ndk, "targets.android.ndk", "release");
39
+ if (to === "dev" || to === "test") {
40
+ const target = config.development?.targets?.[devPlatform];
41
+ if (isObject(target)) {
42
+ for (const [i, step] of (Array.isArray(target.build) ? target.build : []).entries()) if (isObject(step)) add(step.cmd, `development.targets.${devPlatform}.build[${i}]`, Array.isArray(step.args) ? step.args.map(String) : [], typeof step.cwd === "string" ? step.cwd : ".");
43
+ if (devPlatform === "ios") {
44
+ if (target.projectSpec) add("xcodegen", `development.targets.ios.projectSpec ${target.projectSpec} (xcodegen generate)`);
45
+ add("xcodebuild", "development.targets.ios (xcodebuild build)");
46
+ }
47
+ }
48
+ }
49
+ for (const [i, entry] of (Array.isArray(config.sidecars?.sidecars) ? config.sidecars.sidecars : Array.isArray(config.sidecars) ? config.sidecars : []).entries()) {
50
+ if (isObject(entry) && entry.kind === "cmake") add("cmake", `sidecars[${i}] ${entry.name ?? ""} (kind cmake)`);
51
+ }
52
+ return uses;
53
+ }
54
+
55
+ /**
56
+ * The refusal message for a stage's native-tool uses, or null. `capabilities` is the broker's advertised state (`{ ok, capabilities }` from `rustright doctor --json`, or null when it was not
57
+ * queried: every use is then unsupported). `readFile(relative)` reads the COMMIT's bytes (a Gradle wrapper's properties).
58
+ */
59
+ export function nativeToolRefusal(uses, { capabilities = null, readFile = null, listFiles = null } = {}) {
60
+ if (!uses.length) return null;
61
+ const advertised = capabilities?.ok === true && Array.isArray(capabilities.capabilities) ? new Set(capabilities.capabilities) : new Set();
62
+ const problems = [];
63
+ for (const use of uses) {
64
+ if (!advertised.has(use.capability)) { problems.push(`${use.tool} at ${use.where}: the broker does not advertise ${use.capability}${capabilities?.ok === true ? "" : ` (${capabilities?.problem ?? "its capabilities were not queried"})`}`); continue; }
65
+ if (use.tool === "cargo-ndk") { const problem = ndkBlockProblem(use, listFiles); if (problem) problems.push(`${use.tool} at ${use.where}: ${problem}`); continue; }
66
+ if (use.args) {
67
+ const launch = nativeToolLaunch(use.tool === "gradlew" ? "gradle" : use.tool, use.args, { cwd: path.posix.resolve("/", use.cwd ?? ".") });
68
+ if (launch.refusal) { problems.push(`${use.tool} at ${use.where}: ${launch.refusal.message}`); continue; }
69
+ }
70
+ if ((use.tool === "gradle" || use.tool === "gradlew") && typeof readFile === "function") {
71
+ const pin = gradleWrapperPinProblem(readFile, use.cwd ?? ".");
72
+ if (pin) problems.push(`${use.tool} at ${use.where}: ${pin}`);
73
+ }
74
+ }
75
+ if (!problems.length) return null;
76
+ return `${problems.join("; ")} (a broker-managed host never runs a build tool around the broker, rightkit#1241; the capability names are in docs/GATE-CONTRACT.md "Native tool adapters"); nothing ran`;
77
+ }
78
+
79
+ /**
80
+ * GB-62: the reason a declared ndk block cannot run through the pinned cargo-ndk registration, or null. The gate's own validator names a malformed block; the ABIs must be ones RustRight's
81
+ * ndk grammar accepts; and `jniLibs`, the controlled generated output (emptied before every build and bound as the run's output ledger), must hold no committed file.
82
+ */
83
+ function ndkBlockProblem(use, listFiles) {
84
+ let ndk;
85
+ try { ndk = validateAndroidNdk(use.ndk, { root: "/app", kind: use.kind }); } catch (error) { return error.message; }
86
+ const refused = ndk.abis.filter((abi) => !BROKER_NDK_ABIS.includes(abi));
87
+ if (refused.length) return `ndk.abis names ${refused.join(", ")}, which the registered cargo-ndk grammar refuses (only ${BROKER_NDK_ABIS.join(", ")}; ${NDK_EXTERNAL_CAPABILITY})`;
88
+ if (typeof listFiles === "function") {
89
+ let tracked = null;
90
+ try { tracked = listFiles(ndk.jniLibsRelative); } catch { tracked = null; }
91
+ if (Array.isArray(tracked) && tracked.length) return `ndk.jniLibs ${ndk.jniLibsRelative} commits ${tracked.length} file(s) (${tracked.slice(0, 3).join(", ")}): it is the generated output directory (emptied before every build) and must hold no committed file`;
92
+ }
93
+ return null;
94
+ }
@@ -0,0 +1,182 @@
1
+ // The build-ownership record's broker-submit mark and the constants every process under an ownership shares. A LEAF module: it imports
2
+ // only node builtins, so snapshot-broker.mjs (carried sends, in the gate and in its children) and build-ownership.mjs can both use it
3
+ // without an import cycle.
4
+ import crypto from "node:crypto";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import { LOCK_SAME_START_MS, lockBootId, lockProcessIdentity, selfLockIdentity } from "./lock-identity.mjs";
8
+
9
+ export const OWNERSHIP_ENV = "RIGHTKIT_BUILD_OWNERSHIP";
10
+ /** The owner record's path, exported beside the handle so every process under the ownership records detached children there. */
11
+ export const OWNERSHIP_FILE_ENV = "RIGHTKIT_BUILD_OWNERSHIP_FILE";
12
+
13
+ export class OwnershipBusy extends Error {
14
+ constructor(message, detail = {}) { super(message); this.code = "busy"; this.detail = detail; }
15
+ }
16
+
17
+ const readText = (file) => { try { return fs.readFileSync(file, "utf8"); } catch (e) { if (e.code === "ENOENT") return null; throw e; } };
18
+
19
+ export function holds(file, handle) {
20
+ try { return JSON.parse(fs.readFileSync(file, "utf8")).handle === handle; } catch { return false; }
21
+ }
22
+
23
+ /**
24
+ * Records, immediately before ONE snapshot-bound (carried) request is sent, that broker work may exist under this handle: sets
25
+ * `broker.submitted` and appends the request's sub-handle (`<handle>#<uuid>`) to `broker.handles`. From then on the record is
26
+ * reclaimed only on the broker's REAPED for the bare handle. Nothing else marks it: installing a carrier map, an uncarried probe
27
+ * (`rustc -vV`, `--version`, doctor) and a request that was never sent leave `submitted` false, because the broker has no lease
28
+ * for a handle it never saw a snapshot-bound request under and could never answer REAPED. Replaces the record atomically, only
29
+ * while it is still this handle's, under a short lock beside the record (carried calls of parallel children, in other
30
+ * processes, mark the same record).
31
+ */
32
+ export function markBrokerSubmit(file, handle, callHandle, options = {}) {
33
+ if (typeof callHandle !== "string" || callHandle.length <= handle.length + 1 || !callHandle.startsWith(`${handle}#`) || !/^[A-Za-z0-9._-]+$/.test(callHandle.slice(handle.length + 1))) {
34
+ throw new OwnershipBusy(`build ownership ${file}: a broker submit is recorded only for a carried request's sub-handle of ${handle} (<handle>#<id>), not ${JSON.stringify(callHandle ?? null)}`);
35
+ }
36
+ return withMarkLock(file, () => {
37
+ const text = readText(file);
38
+ let owner = null; try { owner = JSON.parse(text ?? ""); } catch { owner = null; }
39
+ if (owner?.handle !== handle) throw new OwnershipBusy(`build ownership ${file} is no longer held by ${handle}; no broker request may be submitted`);
40
+ const seen = Array.isArray(owner.broker?.handles) ? owner.broker.handles : [];
41
+ if (owner.broker?.submitted && seen.includes(callHandle)) return owner;
42
+ const next = { ...owner, broker: { ...(owner.broker ?? {}), submitted: true, at: owner.broker?.at ?? new Date().toISOString(), handles: [...seen, callHandle] } };
43
+ const tmp = `${file}.${crypto.randomUUID()}.tmp`;
44
+ fs.writeFileSync(tmp, JSON.stringify(next), { flag: "wx", mode: 0o600 });
45
+ try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
46
+ if (!holds(file, handle)) throw new OwnershipBusy(`build ownership ${file} changed while recording a broker submit`);
47
+ return next;
48
+ }, options);
49
+ }
50
+
51
+ /**
52
+ * GB-21: records, under the same lock, that the carried request `callHandle` was PROVEN never to have started: the gate could not even start the RustRight client
53
+ * (no such executable, not executable, a refused spawn), so no byte reached the broker. Only a sub-handle already marked as sent can be marked unsent (the mark
54
+ * preceded the attempt); the record keeps `submitted` and the handle, and adds `broker.unsent[callHandle] = { reason, at }`. A reclaim then accepts the sub-handle as
55
+ * settled only when the broker also holds no lease or completion for it (it answers UNKNOWN with no members: it never saw it). An ambiguous send (a timeout, a lost
56
+ * connection after the client started) is never marked unsent: its uncertainty is reconciled by the request id, never erased.
57
+ */
58
+ export function markBrokerUnsent(file, handle, callHandle, reason, options = {}) {
59
+ if (typeof callHandle !== "string" || callHandle.length <= handle.length + 1 || !callHandle.startsWith(`${handle}#`) || !/^[A-Za-z0-9._-]+$/.test(callHandle.slice(handle.length + 1))) {
60
+ throw new OwnershipBusy(`build ownership ${file}: only a sub-handle of ${handle} (<handle>#<id>) can be marked unsent, not ${JSON.stringify(callHandle ?? null)}`);
61
+ }
62
+ if (typeof reason !== "string" || reason === "") throw new OwnershipBusy(`build ownership ${file}: an unsent mark names the local proof that nothing was sent`);
63
+ return withMarkLock(file, () => {
64
+ let owner = null; try { owner = JSON.parse(readText(file) ?? ""); } catch { owner = null; }
65
+ if (owner?.handle !== handle) throw new OwnershipBusy(`build ownership ${file} is no longer held by ${handle}`);
66
+ if (!Array.isArray(owner.broker?.handles) || !owner.broker.handles.includes(callHandle)) throw new OwnershipBusy(`build ownership ${file}: ${callHandle} was never marked as sent, so it cannot be marked unsent`);
67
+ const next = { ...owner, broker: { ...owner.broker, unsent: { ...(owner.broker.unsent ?? {}), [callHandle]: { reason, at: new Date().toISOString() } } } };
68
+ const tmp = `${file}.${crypto.randomUUID()}.tmp`;
69
+ fs.writeFileSync(tmp, JSON.stringify(next), { flag: "wx", mode: 0o600 });
70
+ try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
71
+ if (!holds(file, handle)) throw new OwnershipBusy(`build ownership ${file} changed while recording an unsent mark`);
72
+ return next;
73
+ }, options);
74
+ }
75
+
76
+ const MARK_LOCK_WAIT_MS = 15_000;
77
+ export const MARK_LOCK_SCHEMA = "rightkit.build-ownership-mark-lock/1";
78
+ /** The lock beside the record that serializes `broker.handles` updates. Removed with the record (release, reclaim). */
79
+ export const markLockFile = (file) => `${file}.mark.lock`;
80
+ const stealClaimFile = (file, nonce) => `${markLockFile(file)}.steal-${nonce}`;
81
+
82
+ const defaultSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
83
+ const parseLock = (text) => { try { const rec = JSON.parse(text); return rec && typeof rec === "object" && rec.schema === MARK_LOCK_SCHEMA && typeof rec.nonce === "string" && /^[0-9a-f-]{36}$/.test(rec.nonce) ? rec : null; } catch { return null; } };
84
+ const readLockText = (lock) => { try { return fs.readFileSync(lock, "utf8"); } catch (e) { if (e.code === "ENOENT") return null; throw e; } };
85
+
86
+ /**
87
+ * Whether the holder recorded in a lock is `"dead"` (proven), `"live"`, or `"unknown"`. Only `"dead"` ever allows a steal: age proves nothing
88
+ * (a suspended live holder can resume), so a lock is taken from its holder only on proof, the same proofs the ownership record uses
89
+ * (build-ownership.mjs judgeOwner): the host rebooted (boot ids differ), the pid is gone, or the pid now runs a process that started at another
90
+ * time. Anything unreadable, another host, or an identity that cannot be read is not proof.
91
+ */
92
+ export function judgeLockHolder(rec, { identity, host = os.hostname() }) {
93
+ if (!rec || !Number.isInteger(rec.pid) || rec.pid <= 0) return "unknown";
94
+ if (rec.host !== host) return "unknown";
95
+ let boot = null; try { boot = identity.bootId(); } catch { boot = null; }
96
+ if (typeof rec.bootId === "string" && rec.bootId && typeof boot === "string" && boot && rec.bootId !== boot) return "dead";
97
+ let now;
98
+ try { now = identity.probe(rec.pid); } catch { return "unknown"; }
99
+ if (!now.alive) return "dead";
100
+ if (!Number.isFinite(rec.startedMs) || !Number.isFinite(now.startedMs)) return "unknown";
101
+ return Math.abs(now.startedMs - rec.startedMs) <= LOCK_SAME_START_MS ? "live" : "dead";
102
+ }
103
+
104
+ /** Creates `target` holding `record` atomically and exclusively: the content is complete the moment the name exists (write a temp file, hard-link it). */
105
+ function createExclusive(target, record) {
106
+ const tmp = `${target}.${record.nonce}.tmp`;
107
+ fs.writeFileSync(tmp, JSON.stringify(record), { flag: "wx", mode: 0o600 });
108
+ try { fs.linkSync(tmp, target); return true; }
109
+ catch (e) { if (e.code === "EEXIST") return false; throw e; }
110
+ finally { fs.rmSync(tmp, { force: true }); }
111
+ }
112
+
113
+ /**
114
+ * Runs `fn` under the lock beside the record. The lock names its holder (pid, start time, boot id) and carries a random nonce:
115
+ * - a holder that is alive is never displaced, however long it holds (it may be suspended and resume);
116
+ * - a holder PROVEN dead is displaced by one claimant per observed lock (a claim file named by the dead lock's nonce), which removes
117
+ * the lock only while it is still the exact one it judged;
118
+ * - when liveness is uncertain the caller waits, and past the deadline fails closed with OwnershipBusy (no request is sent);
119
+ * - release removes the lock only while it still carries this holder's nonce, so a holder that somehow lost it never removes a successor's.
120
+ * `options` are seams for tests: `identity` ({ self(), probe(pid), bootId() }), `sleep`, `waitMs`.
121
+ */
122
+ export function withMarkLock(file, fn, options = {}) {
123
+ const lock = markLockFile(file);
124
+ const identity = options.identity ?? { self: () => selfLockIdentity(), probe: (pid) => lockProcessIdentity(pid), bootId: () => lockBootId() };
125
+ const sleep = options.sleep ?? defaultSleep;
126
+ const deadline = Date.now() + (options.waitMs ?? MARK_LOCK_WAIT_MS);
127
+ let me;
128
+ try { me = identity.self(); } catch (e) { throw new OwnershipBusy(`build ownership ${file}: this process's identity cannot be read (${e.message}), so it cannot take the submit lock`); }
129
+ const record = { schema: MARK_LOCK_SCHEMA, pid: me.pid, startedMs: me.startedMs, bootId: me.bootId ?? null, host: os.hostname(), nonce: crypto.randomUUID(), at: new Date().toISOString() };
130
+ const take = (target) => {
131
+ try { return createExclusive(target, record); }
132
+ catch (e) { throw new OwnershipBusy(`build ownership ${file}: the submit lock cannot be taken (${e.code ?? e.message})`); }
133
+ };
134
+ let lastVerdict = "unknown";
135
+ for (;;) {
136
+ if (take(lock)) break;
137
+ let text;
138
+ try { text = readLockText(lock); } catch (e) { throw new OwnershipBusy(`build ownership ${file}: the submit lock cannot be read (${e.code ?? e.message})`); }
139
+ if (text === null) continue; // it was released between the create and the read
140
+ const observed = parseLock(text);
141
+ lastVerdict = observed ? judgeLockHolder(observed, { identity }) : "unknown";
142
+ if (lastVerdict === "dead" && stealDeadLock({ file, lock, text, observed, record, identity, take })) continue;
143
+ if (Date.now() > deadline) throw new OwnershipBusy(`build ownership ${file}: the submit lock ${lock} is held${observed ? ` by pid ${observed.pid}` : " by a holder that cannot be read"} and ${lastVerdict === "live" ? "is alive" : "its liveness cannot be proven"}; no broker request is sent`, { holder: observed, verdict: lastVerdict });
144
+ sleep(10);
145
+ }
146
+ try { return fn(); } finally {
147
+ try { const held = parseLock(readLockText(lock) ?? ""); if (held?.nonce === record.nonce) fs.rmSync(lock, { force: true }); } catch { /* the lock stays; a later caller proves its holder dead */ }
148
+ }
149
+ }
150
+
151
+ /** Removes the lock `text`/`observed`, whose holder is proven dead, if this process wins the one claim for that lock. Returns whether the retry should go straight on. */
152
+ function stealDeadLock({ file, lock, text, observed, record, identity, take }) {
153
+ const claim = stealClaimFile(file, observed.nonce);
154
+ if (!take(claim)) {
155
+ // Another process is stealing the same dead lock, or died doing it: a claim whose holder is proven dead is dropped, anything else waits.
156
+ const claimText = readLockText(claim);
157
+ if (claimText !== null && judgeLockHolder(parseLock(claimText), { identity }) === "dead") fs.rmSync(claim, { force: true });
158
+ return false;
159
+ }
160
+ try {
161
+ if (readLockText(lock) === text) fs.rmSync(lock, { force: true });
162
+ } finally {
163
+ try { if (parseLock(readLockText(claim) ?? "")?.nonce === record.nonce) fs.rmSync(claim, { force: true }); } catch { /* a dead claim is dropped by the next claimant */ }
164
+ }
165
+ return true;
166
+ }
167
+
168
+ /**
169
+ * Marks the record at `file` as having sent the carried request `callHandle`, for a caller that holds no ownership object (a
170
+ * right-release child of the gate, in another process, which inherits the handle and file in its environment). Throws
171
+ * OwnershipBusy unless `handle` is still the record's owner.
172
+ */
173
+ export function markCarriedSend({ file, handle, callHandle }) {
174
+ if (typeof file !== "string" || !file || typeof handle !== "string" || !handle) throw new OwnershipBusy("a carried request is marked only under a build ownership file and handle");
175
+ return markBrokerSubmit(file, handle, callHandle);
176
+ }
177
+
178
+ /** The caller that holds no ownership object (a right-release child): the same unsent mark under the inherited handle and file. */
179
+ export function markCarriedUnsent({ file, handle, callHandle, reason }) {
180
+ if (typeof file !== "string" || !file || typeof handle !== "string" || !handle) throw new OwnershipBusy("an unsent mark is recorded only under a build ownership file and handle");
181
+ return markBrokerUnsent(file, handle, callHandle, reason);
182
+ }
@@ -0,0 +1,122 @@
1
+ // The content digest of an npm archive: sha256 over the sorted list of (path, type, exec bit, sha256 of the content) of its entries.
2
+ // The archive's own bytes depend on gzip, entry order and mtimes; this digest depends only on what the package ships, so two packs of
3
+ // one commit compare equal whatever produced them. It is read with node:zlib and a strict ustar/pax reader (no system tar: the listing
4
+ // `tar -tzf` carries no mode or type, and the hosted runner and the local host must agree on one reading).
5
+ //
6
+ // Exclusions are code constants, never input (rightkit#1301 B2): the one binary whose bytes differ between a local pack and the hosted
7
+ // one is @rightkit/release's Developer-ID-signed `bin/darwin/rightkit-swap`, which is re-signed with a timestamp on every signing, and
8
+ // its `.sign.json` receipt. `exclusionsFor` returns them for that one package; every other unit has an empty set. The hosted side binds
9
+ // the excluded helper separately (publish-admission.mjs bindPackedToQualification: its sha256 equals the sign-swap job's).
10
+ import { createHash } from "node:crypto";
11
+ import fs from "node:fs";
12
+ import zlib from "node:zlib";
13
+ import { canonicalJson } from "./provenance-verify.mjs";
14
+ import { SWAP_HELPER_BIN, SWAP_HELPER_PACKAGE, SWAP_HELPER_RECEIPT } from "./swap-helper-bundle.mjs";
15
+
16
+ export const CONTENT_DIGEST_DOMAIN = "rightkit-npm-content-v1";
17
+ /** An npm tarball's entries all live under `package/`. */
18
+ const ROOT = "package";
19
+ export const SWAP_HELPER_ARCHIVE_BIN = `${ROOT}/${SWAP_HELPER_BIN.join("/")}`;
20
+ export const SWAP_HELPER_ARCHIVE_RECEIPT = `${ROOT}/${SWAP_HELPER_RECEIPT.join("/")}`;
21
+
22
+ /** The archive paths left out of a unit's content digest: the swap helper and its receipt for @rightkit/release, nothing for any other unit. */
23
+ export function exclusionsFor(unitName) {
24
+ return unitName === SWAP_HELPER_PACKAGE ? [SWAP_HELPER_ARCHIVE_BIN, SWAP_HELPER_ARCHIVE_RECEIPT] : [];
25
+ }
26
+
27
+ const sha256 = (data) => createHash("sha256").update(data).digest("hex");
28
+ const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
29
+ const TYPES = { 0: "file", "\0": "file", 1: "hardlink", 2: "symlink", 5: "directory" };
30
+
31
+ const field = (block, start, length) => { const end = block.indexOf(0, start); return block.toString("utf8", start, end === -1 || end > start + length ? start + length : end); };
32
+ const octal = (block, start, length, what) => {
33
+ // A size or mode field is octal ASCII, or base-256 (high bit of the first byte) for a size beyond 8 GiB.
34
+ if (block[start] & 0x80) { let n = 0n; for (let i = 1; i < length; i += 1) n = (n << 8n) | BigInt(block[start + i]); return Number(n); }
35
+ const text = block.toString("ascii", start, start + length).replace(/[\0 ]+$/g, "").trim();
36
+ if (text === "") return 0;
37
+ if (!/^[0-7]+$/.test(text)) throw new Error(`the archive has a malformed ${what} field`);
38
+ return Number.parseInt(text, 8);
39
+ };
40
+
41
+ function paxRecords(data) {
42
+ const out = {};
43
+ let at = 0;
44
+ while (at < data.length) {
45
+ const space = data.indexOf(0x20, at);
46
+ const length = Number.parseInt(data.toString("ascii", at, space), 10);
47
+ if (!Number.isInteger(length) || space < 0 || length < space - at + 2 || at + length > data.length) throw new Error("the archive has a malformed pax header");
48
+ const record = data.toString("utf8", space + 1, at + length - 1);
49
+ const eq = record.indexOf("=");
50
+ if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
51
+ at += length;
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** The entries of a gunzipped tar: `[{ path, type, mode, data }]`, in archive order. Refuses what it cannot read exactly. */
57
+ export function parseTar(tar) {
58
+ const entries = [];
59
+ let at = 0;
60
+ let pending = {}; // pax/GNU overrides for the next entry
61
+ let ended = false;
62
+ while (at + 512 <= tar.length) {
63
+ const header = tar.subarray(at, at + 512);
64
+ if (header.every((b) => b === 0)) { ended = true; break; }
65
+ const stored = octal(header, 148, 8, "checksum");
66
+ let sum = 0;
67
+ for (let i = 0; i < 512; i += 1) sum += i >= 148 && i < 156 ? 0x20 : header[i];
68
+ if (sum !== stored) throw new Error("the archive has a header with a bad checksum");
69
+ const size = octal(header, 124, 12, "size");
70
+ const flag = String.fromCharCode(header[156]);
71
+ const bodyStart = at + 512;
72
+ const bodyEnd = bodyStart + size;
73
+ if (bodyEnd > tar.length) throw new Error("the archive is truncated");
74
+ const body = tar.subarray(bodyStart, bodyEnd);
75
+ at = bodyStart + Math.ceil(size / 512) * 512;
76
+ if (flag === "x") {
77
+ const records = paxRecords(body);
78
+ if (records.size !== undefined) throw new Error("the archive overrides an entry size in a pax header");
79
+ pending = { ...pending, ...records };
80
+ continue;
81
+ }
82
+ if (flag === "g") throw new Error("the archive carries a global pax header");
83
+ if (flag === "L") { pending.path = body.toString("utf8").replace(/\0+$/, ""); continue; }
84
+ if (flag === "K") { pending.linkpath = body.toString("utf8").replace(/\0+$/, ""); continue; }
85
+ const type = TYPES[flag];
86
+ if (!type) throw new Error(`the archive has an entry of unsupported type ${JSON.stringify(flag)}`);
87
+ const ustar = header.toString("ascii", 257, 262) === "ustar";
88
+ const prefix = ustar ? field(header, 345, 155) : "";
89
+ const name = pending.path ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100));
90
+ const linkpath = pending.linkpath ?? field(header, 157, 100);
91
+ pending = {};
92
+ entries.push({ path: name.replace(/\/+$/, ""), type, mode: octal(header, 100, 8, "mode"), data: type === "file" ? body : type === "directory" ? Buffer.alloc(0) : Buffer.from(linkpath, "utf8") });
93
+ }
94
+ if (!ended) throw new Error("the archive is truncated: it has no end marker");
95
+ if (Object.keys(pending).length) throw new Error("the archive ends after a header with no entry");
96
+ return entries;
97
+ }
98
+
99
+ /**
100
+ * `{ contentSha256, entries, excluded }` of an npm archive. `exclude` lists archive paths (`package/...`) left out of the digest; each one
101
+ * present is reported in `excluded` as `{ type, exec, sha256 }`, so a caller binds it by another route. Two entries of one path, an
102
+ * unreadable header or an unsupported entry type refuse (an archive that reads two ways has no one digest).
103
+ */
104
+ export function npmContentDigest(tgz, { exclude = [] } = {}) {
105
+ let tar;
106
+ try { tar = zlib.gunzipSync(Buffer.isBuffer(tgz) ? tgz : fs.readFileSync(tgz), { maxOutputLength: MAX_ARCHIVE_BYTES }); }
107
+ catch (error) { throw new Error(`the archive is not a readable gzip (${error.message})`); }
108
+ const skip = new Set(exclude);
109
+ const rows = [];
110
+ const excluded = {};
111
+ const seen = new Set();
112
+ for (const entry of parseTar(tar)) {
113
+ if (seen.has(entry.path)) throw new Error(`the archive holds ${entry.path} twice`);
114
+ seen.add(entry.path);
115
+ const row = { path: entry.path, type: entry.type, exec: (entry.mode & 0o111) !== 0, sha256: sha256(entry.data) };
116
+ if (skip.has(entry.path)) excluded[entry.path] = { type: row.type, exec: row.exec, sha256: row.sha256 };
117
+ else rows.push(row);
118
+ }
119
+ rows.sort((a, b) => Buffer.compare(Buffer.from(a.path, "utf8"), Buffer.from(b.path, "utf8")));
120
+ const contentSha256 = sha256(`${CONTENT_DIGEST_DOMAIN}\n${canonicalJson(rows.map((r) => [r.path, r.type, r.exec, r.sha256]))}`);
121
+ return { contentSha256, entries: rows.length, excluded };
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -45,9 +45,7 @@
45
45
  },
46
46
  "rightkit": {
47
47
  "publishCheck": {
48
- "skipImport": [
49
- "./sign-windows.mjs"
50
- ],
48
+ "skipImport": [],
51
49
  "binSmoke": {
52
50
  "right-release": [
53
51
  "--version"
@@ -82,6 +80,14 @@
82
80
  "!provenance-real-receipt.fixture.json"
83
81
  ],
84
82
  "sideEffects": false,
83
+ "scripts": {
84
+ "test": "node test-support/run-test-shards.mjs",
85
+ "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
86
+ "test:signer:windows": "node sign-release-manifest.integration.mjs",
87
+ "prepublishOnly": "pnpm test && pnpm test:signer:windows && pnpm test:registry-parity",
88
+ "doctor:all": "node --test right-suite-contract.test.mjs",
89
+ "verify:standalone": "node standalone-clone-verify.mjs"
90
+ },
85
91
  "publishConfig": {
86
92
  "registry": "https://registry.npmjs.org/",
87
93
  "access": "public"
@@ -91,15 +97,9 @@
91
97
  "url": "git+https://github.com/bogusyogi/rightkit.git",
92
98
  "directory": "packages/release"
93
99
  },
100
+ "packageManager": "pnpm@12.16.1",
94
101
  "dependencies": {
95
102
  "@rightkit/outdated": "0.3.0",
96
103
  "smol-toml": "1.9.1"
97
- },
98
- "scripts": {
99
- "test": "node --import ./test-support/broker-guard.mjs --test --test-concurrency=1 --test-force-exit *.test.mjs cli/*.test.mjs",
100
- "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
101
- "test:signer:windows": "node sign-release-manifest.integration.mjs",
102
- "doctor:all": "node --test right-suite-contract.test.mjs",
103
- "verify:standalone": "node standalone-clone-verify.mjs"
104
104
  }
105
- }
105
+ }