@rightkit/release 0.2.49 → 0.2.51

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/build-release.mjs CHANGED
@@ -26,6 +26,8 @@ import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteV
26
26
  import { createTargetBridge } from "./target-bridge.mjs";
27
27
  import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
28
28
  import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
29
+ import { assertCleanSource } from "./source-gate.mjs";
30
+ import { acquireHeavyWorkSlot, heavyCommandEnvironment, terminateProcessTree } from "./heavy-command.mjs";
29
31
 
30
32
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
31
33
  function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
@@ -79,11 +81,11 @@ if (relativeConfig.startsWith("../")) fail("release config must live inside the
79
81
  const appRoot = path.dirname(configPath);
80
82
  const layout = resolveReleaseLayout({ repoRoot, configPath });
81
83
  const vaultRoot = layout.vaultRoot;
82
- mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
83
- const lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
84
+ let lock;
84
85
  let child = null;
85
86
  let suiteSlot;
86
87
  let cacheLease;
88
+ let heavySlot;
87
89
  let interrupted;
88
90
 
89
91
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
@@ -97,6 +99,12 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
97
99
  try {
98
100
  throwIfInterrupted();
99
101
  const commit = git(repoRoot, ["rev-parse", "HEAD"]);
102
+ assertCleanSource({
103
+ status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
104
+ commandId: "right-release build",
105
+ });
106
+ mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
107
+ lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
100
108
  const shortCommit = commit.slice(0, 8);
101
109
  const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
102
110
  if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
@@ -143,7 +151,7 @@ try {
143
151
  // Perl first so the build behaves the same from any shell.
144
152
  const pathPrefix = buildPathPrefix({ platform });
145
153
  const env = {
146
- ...process.env,
154
+ ...heavyCommandEnvironment(process.env),
147
155
  ...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
148
156
  ...(pathPrefix ? { PATH: `${pathPrefix}${path.delimiter}${process.env.PATH ?? ""}` } : {}),
149
157
  RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
@@ -152,6 +160,7 @@ try {
152
160
  if (cacheMode === "legacy") delete env.RIGHT_RELEASE_CACHE_OWNER;
153
161
  if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
154
162
  if (cacheMode === "legacy" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
163
+ if (process.env.RIGHTSUITE_HEAVY_WORK_OWNER !== "1") heavySlot = acquireHeavyWorkSlot();
155
164
  if (cacheMode === "shared") {
156
165
  suiteSlot = acquireSuiteBuildSlot({ layout: sharedLayout, pid: process.pid, argv: process.argv, waitMs: Number(process.env.BUILD_GUARD_WAIT_SECS ?? 900) * 1000 });
157
166
  cacheLease = acquireCacheLease({ layout: sharedLayout, pid: process.pid, argv: process.argv });
@@ -254,7 +263,8 @@ try {
254
263
  } finally {
255
264
  cacheLease?.release();
256
265
  suiteSlot?.release();
257
- lock.release();
266
+ heavySlot?.release();
267
+ lock?.release();
258
268
  }
259
269
 
260
270
  function hashInputs(files) {
@@ -354,7 +364,16 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir) {
354
364
  let lastMtime = Math.max(...watchDirs.map(newestMtime));
355
365
  const started = Date.now();
356
366
  await new Promise((resolve, reject) => {
357
- child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32" });
367
+ child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32", detached: process.platform !== "win32" });
368
+ try {
369
+ if (child.pid && heavySlot) heavySlot.trackChild(child.pid, cmd);
370
+ } catch (error) {
371
+ child.once("error", () => {});
372
+ if (child.pid) killTree(child.pid);
373
+ child = null;
374
+ reject(error);
375
+ return;
376
+ }
358
377
  const closeWatchers = watchProgress(watchDirs, () => { lastProgress = Date.now(); });
359
378
  for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
360
379
  stream.on("data", (chunk) => { lastProgress = Date.now(); output.write(chunk); });
@@ -454,9 +473,7 @@ function runChecked(cmd, runArgs, cwd, env = process.env) {
454
473
  }
455
474
 
456
475
  function killTree(pid) {
457
- if (!pid) return;
458
- if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
459
- else { try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } } }
476
+ terminateProcessTree(pid);
460
477
  }
461
478
 
462
479
  function writeJson(file, value) {
@@ -7,15 +7,22 @@ import { fileURLToPath } from "node:url";
7
7
  const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "build-release.mjs"), "utf8");
8
8
 
9
9
  test("macOS and Windows builds default to Cache V2 while explicit legacy mode remains available", () => {
10
+ assert.match(source, /assertCleanSource/);
11
+ assert.match(source, /right-release build/);
10
12
  assert.match(source, /RIGHT_RELEASE_CACHE_MODE/);
11
13
  assert.match(source, /platform === "mac" \|\| platform === "win" \? "shared" : "legacy"/);
12
14
  assert.match(source, /cacheMode !== "legacy" && cacheMode !== "shared"/);
13
15
  assert.match(source, /resolveSharedCacheRoot/);
14
16
  assert.match(source, /acquireSuiteBuildSlot/);
17
+ assert.match(source, /acquireHeavyWorkSlot/);
18
+ assert.match(source, /heavyCommandEnvironment/);
19
+ assert.match(source, /heavySlot\.trackChild/);
20
+ assert.match(source, /detached: process\.platform !== "win32"/);
15
21
  assert.match(source, /acquireCacheLease/);
16
22
  assert.match(source, /markCacheEntrySuccessful/);
17
23
  assert.match(source, /RIGHT_RELEASE_CACHE_OWNER/);
18
24
  assert.match(source, /suiteSlot\?\.release\(\)/);
25
+ assert.match(source, /heavySlot\?\.release\(\)/);
19
26
  assert.match(source, /cacheLease\?\.release\(\)/);
20
27
  assert.doesNotMatch(source, /process\.exit\(signal/);
21
28
  assert.match(source, /throwIfInterrupted/);
package/cache-command.mjs CHANGED
@@ -58,7 +58,7 @@ export async function runCacheCommand(args = process.argv.slice(2), { env = proc
58
58
 
59
59
  function output(value, json, stdout) {
60
60
  if (json) stdout(JSON.stringify(value));
61
- else if (value.command === "status") stdout(`RightKit cache root: ${value.cacheRoot}\nTargets: ${value.targetBytes} bytes\nFree: ${(value.cacheFreeBytes / 1024 ** 3).toFixed(1)} GiB\nLeases: ${value.entries.filter((entry) => entry.leased).length}`);
61
+ else if (value.command === "status") stdout(`RightKit cache root: ${value.cacheRoot}\nTargets: ${value.targetBytes} bytes\nBackups: ${value.backupBytes} bytes\nFree: ${(value.cacheFreeBytes / 1024 ** 3).toFixed(1)} GiB\nLeases: ${value.entries.filter((entry) => entry.leased).length}`);
62
62
  else stdout(`RightKit cache ${value.command}: ${value.dryRun ? "dry-run" : "applied"}\nRoot: ${value.cacheRoot}\nCandidates: ${(value.candidateIds ?? value.plan?.candidateIds ?? []).join(", ") || "none"}\nReclaimed: ${value.reclaimedBytes ?? 0} bytes`);
63
63
  return value;
64
64
  }
@@ -19,6 +19,7 @@ test("cache status has human and schema-stable JSON output", () => {
19
19
  const json = run(["status", "--json"], cacheRoot);
20
20
  assert.equal(human.status, 0, human.stderr);
21
21
  assert.match(human.stdout, /RightKit cache root:/);
22
+ assert.match(human.stdout, /Backups: 0 bytes/);
22
23
  assert.equal(json.status, 0, json.stderr);
23
24
  assert.equal(JSON.parse(json.stdout).schema, 1);
24
25
  });
package/cache-policy.mjs CHANGED
@@ -24,6 +24,7 @@ export const DEFAULT_TARGET_MAX_BYTES = 12 * 1024 ** 3;
24
24
  export const DEFAULT_SCCACHE_MAX_BYTES = 32 * 1024 ** 3;
25
25
  export const DEFAULT_DESIRED_FREE_BYTES = 60 * 1024 ** 3;
26
26
  export const DEFAULT_HARD_FREE_BYTES = 25 * 1024 ** 3;
27
+ export const DEFAULT_BACKUP_RETENTION_DAYS = 7;
27
28
  // Any build holds release -> suite slot -> GC -> entry lease; prune holds only GC.
28
29
  export const CACHE_LOCK_ORDER = ["release", "suite-build-slot", "gc", "entry-lease"];
29
30
 
@@ -84,6 +85,7 @@ export function readCachePolicy(env = process.env) {
84
85
  sccacheMaxBytes: readPositive(env.RIGHT_RELEASE_SCCACHE_MAX_BYTES, DEFAULT_SCCACHE_MAX_BYTES),
85
86
  desiredFreeBytes: readPositive(env.RIGHT_RELEASE_DESIRED_FREE_BYTES, DEFAULT_DESIRED_FREE_BYTES),
86
87
  hardFreeBytes: readPositive(env.RIGHT_RELEASE_HARD_FREE_BYTES, DEFAULT_HARD_FREE_BYTES),
88
+ backupRetentionDays: readPositive(env.RIGHT_RELEASE_BACKUP_RETENTION_DAYS, DEFAULT_BACKUP_RETENTION_DAYS),
87
89
  };
88
90
  }
89
91
 
@@ -197,7 +199,7 @@ export function markCacheEntrySuccessful({ layout, now = new Date() } = {}) {
197
199
 
198
200
  export function inspectCache({ cacheRoot, statfs = statfsSync, now = new Date() } = {}) {
199
201
  const root = readonlyRoot(cacheRoot);
200
- if (!root) return { cacheRoot: path.resolve(cacheRoot), entries: [], targetBytes: 0, cacheFreeBytes: 0, device: undefined, inspectedAt: iso(now) };
202
+ if (!root) return { cacheRoot: path.resolve(cacheRoot), entries: [], backups: [], targetBytes: 0, backupBytes: 0, cacheFreeBytes: 0, device: undefined, inspectedAt: iso(now) };
201
203
  const entries = [];
202
204
  for (const kind of ["targets", "test-targets"]) {
203
205
  const kindRoot = path.join(root, kind);
@@ -215,8 +217,16 @@ export function inspectCache({ cacheRoot, statfs = statfsSync, now = new Date()
215
217
  entries.push({ id: marker.id, dir, marker, bytes: directoryBytes(dir), leased: live, lease: live ? lease : null });
216
218
  }
217
219
  }
220
+ const backups = [];
221
+ for (const namespace of BACKUP_NAMESPACES) {
222
+ const namespaceRoot = path.join(root, namespace);
223
+ for (const entry of safeDirEntries(namespaceRoot)) {
224
+ const stat = lstatSync(entry.path);
225
+ backups.push({ id: `backup:${namespace}:${entry.name}`, namespace, name: entry.name, dir: entry.path, bytes: directoryBytes(entry.path), mtimeMs: stat.mtimeMs });
226
+ }
227
+ }
218
228
  const fs = statfs(root, { bigint: false });
219
- return { cacheRoot: root, entries, targetBytes: entries.filter((x) => x.marker.kind === "release").reduce((sum, x) => sum + x.bytes, 0), cacheFreeBytes: freeBytes(fs), device: fs.dev ?? safeDevice(root), inspectedAt: iso(now) };
229
+ return { cacheRoot: root, entries, backups, targetBytes: entries.filter((x) => x.marker.kind === "release").reduce((sum, x) => sum + x.bytes, 0), backupBytes: backups.reduce((sum, x) => sum + x.bytes, 0), cacheFreeBytes: freeBytes(fs), device: fs.dev ?? safeDevice(root), inspectedAt: iso(now) };
220
230
  }
221
231
 
222
232
  export function inspectWriteVolumes({ repoRoot, vaultRoot, cacheRoot, statfs = statfsSync } = {}) {
@@ -253,6 +263,10 @@ export function planCachePrune({ snapshot, policy = readCachePolicy(), protected
253
263
  let bytes = snapshot.targetBytes ?? 0;
254
264
  let free = snapshot.cacheFreeBytes ?? Infinity;
255
265
  const selected = [];
266
+ const backupCutoff = new Date(snapshot.inspectedAt ?? Date.now()).getTime() - policy.backupRetentionDays * 86_400_000;
267
+ for (const entry of snapshot.backups ?? []) {
268
+ if (entry.mtimeMs < backupCutoff) selected.push({ id: entry.id, dir: entry.dir, bytes: entry.bytes, reason: "expired-backup" });
269
+ }
256
270
  for (const entry of candidates) {
257
271
  if (bytes <= policy.targetMaxBytes && free >= policy.desiredFreeBytes) break;
258
272
  selected.push({ id: entry.id, dir: entry.dir, bytes: entry.bytes, reason: bytes > policy.targetMaxBytes ? "target-cap" : "free-space" });
@@ -426,10 +440,18 @@ function acquireAtomicLock(file, payload, label, now) {
426
440
  function releaseFor(file, payload) { let released = false; return { release() { if (released) return; released = true; const current = readJson(file); if (current?.pid === payload.pid && current?.createdAt === payload.createdAt) safeUnlink(file); } }; }
427
441
  function touchEntry(layout, now) { const entry = readJson(layout.markerPath); if (!validEntry(entry, layout)) throw new Error(`invalid cache entry marker: ${layout.markerPath}`); entry.lastUsedAt = iso(now); atomicJson(layout.markerPath, entry); }
428
442
  function defaultSleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
443
+ const BACKUP_NAMESPACES = ["recovery", "manual-backups", "dev-target-backups", "legacy-backups"];
429
444
  function safePruneCandidate(root, candidate) {
430
445
  if (!candidate?.dir || path.resolve(candidate.dir) === root) return false;
431
446
  if (!existsSync(candidate.dir) || lstatSync(candidate.dir).isSymbolicLink()) return false;
432
- const dir = realpathSync(candidate.dir); const targets = [path.join(root, "targets"), path.join(root, "test-targets")];
447
+ const dir = realpathSync(candidate.dir);
448
+ if (candidate.id?.startsWith("backup:")) {
449
+ const [, namespace, name] = candidate.id.split(":");
450
+ if (!BACKUP_NAMESPACES.includes(namespace) || !name || path.basename(dir) !== name) return false;
451
+ const backupRoot = path.join(root, namespace);
452
+ try { return path.dirname(dir) === backupRoot && realpathSync(backupRoot) === backupRoot; } catch { return false; }
453
+ }
454
+ const targets = [path.join(root, "targets"), path.join(root, "test-targets")];
433
455
  const targetRoot = targets.find((item) => { try { assertLexicalInside(item, dir); return true; } catch { return false; } });
434
456
  if (!targetRoot) return false;
435
457
  try {
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
@@ -8,6 +8,7 @@ import { once } from "node:events";
8
8
 
9
9
  import {
10
10
  CACHE_SCHEMA,
11
+ DEFAULT_BACKUP_RETENTION_DAYS,
11
12
  DEFAULT_DESIRED_FREE_BYTES,
12
13
  DEFAULT_HARD_FREE_BYTES,
13
14
  DEFAULT_SCCACHE_MAX_BYTES,
@@ -17,6 +18,7 @@ import {
17
18
  applyCachePrune,
18
19
  assertWriteVolumeFloors,
19
20
  ensureCacheEntry,
21
+ inspectCache,
20
22
  inspectWriteVolumes,
21
23
  markCacheEntrySuccessful,
22
24
  migrateLegacyCache,
@@ -80,6 +82,30 @@ test("policy defaults are the approved target, compiler, desired and hard limits
80
82
  assert.equal(policy.sccacheMaxBytes, DEFAULT_SCCACHE_MAX_BYTES);
81
83
  assert.equal(policy.desiredFreeBytes, DEFAULT_DESIRED_FREE_BYTES);
82
84
  assert.equal(policy.hardFreeBytes, DEFAULT_HARD_FREE_BYTES);
85
+ assert.equal(policy.backupRetentionDays, DEFAULT_BACKUP_RETENTION_DAYS);
86
+ });
87
+
88
+ test("pruning expires only old direct backup directories", () => {
89
+ const base = root();
90
+ const old = path.join(base, "manual-backups", "old-target");
91
+ const recent = path.join(base, "recovery", "recent-target");
92
+ const outside = root();
93
+ mkdirSync(old, { recursive: true }); writeFileSync(path.join(old, "artifact"), "old");
94
+ mkdirSync(recent, { recursive: true }); writeFileSync(path.join(recent, "artifact"), "recent");
95
+ mkdirSync(outside, { recursive: true });
96
+ mkdirSync(path.join(base, "legacy-backups"), { recursive: true });
97
+ symlinkSync(outside, path.join(base, "legacy-backups", "escape"));
98
+ utimesSync(old, new Date("2026-07-01T00:00:00Z"), new Date("2026-07-01T00:00:00Z"));
99
+ utimesSync(recent, new Date("2026-08-07T00:00:00Z"), new Date("2026-08-07T00:00:00Z"));
100
+ const snapshot = inspectCache({ cacheRoot: base, now: new Date("2026-08-08T00:00:00Z"), statfs: () => ({ bavail: 100, bsize: 1 }) });
101
+ assert.deepEqual(snapshot.backups.map((entry) => entry.id).sort(), ["backup:manual-backups:old-target", "backup:recovery:recent-target"]);
102
+ const plan = planCachePrune({ snapshot, policy: { ...readCachePolicy({}), targetMaxBytes: 1, desiredFreeBytes: 0 } });
103
+ assert.deepEqual(plan.candidateIds, ["backup:manual-backups:old-target"]);
104
+ const result = applyCachePrune({ plan, cacheRoot: base, dryRun: false });
105
+ assert.deepEqual(result.removedIds, ["backup:manual-backups:old-target"]);
106
+ assert.equal(existsSync(old), false);
107
+ assert.equal(existsSync(recent), true);
108
+ assert.equal(existsSync(outside), true);
83
109
  });
84
110
 
85
111
  test("entry metadata is atomic, complete, and updates success only after requested", () => {
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { runHeavyCommand } from "./heavy-command.mjs";
7
+
8
+ const LIGHT_COMMANDS = new Set(["fmt", "metadata", "tree", "fetch", "search", "locate-project", "read-manifest", "help", "version", "--version", "-V"]);
9
+ const GLOBAL_OPTIONS_WITH_VALUE = new Set(["--color", "--config", "-Z"]);
10
+
11
+ export function cargoSubcommand(args) {
12
+ for (let index = 0; index < args.length; index += 1) {
13
+ const value = args[index];
14
+ if (value.startsWith("+") || value.startsWith("--color=") || value.startsWith("--config=")) continue;
15
+ if (GLOBAL_OPTIONS_WITH_VALUE.has(value)) { index += 1; continue; }
16
+ if (value.startsWith("-Z")) continue;
17
+ if (!value.startsWith("-") || value === "--version" || value === "-V") return value;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ export function shouldGuardCargo(args) {
23
+ const command = cargoSubcommand(args);
24
+ return command !== null && !LIGHT_COMMANDS.has(command);
25
+ }
26
+
27
+ export function resolveRealCargo({ env = process.env, run = spawnSync } = {}) {
28
+ if (env.RIGHTSUITE_REAL_CARGO) return path.resolve(env.RIGHTSUITE_REAL_CARGO);
29
+ const rustup = process.platform === "win32" ? "rustup.exe" : "rustup";
30
+ const result = run(rustup, ["which", "cargo"], { encoding: "utf8", windowsHide: true });
31
+ const cargo = String(result.stdout ?? "").trim();
32
+ if (result.status !== 0 || !cargo) throw new Error(`cargo-guard could not resolve real Cargo: ${String(result.stderr ?? "").trim()}`);
33
+ return path.resolve(cargo);
34
+ }
35
+
36
+ function spawnCargo(command, args, env) {
37
+ return new Promise((resolve, reject) => {
38
+ const child = spawn(command, args, { env, stdio: "inherit", windowsHide: true });
39
+ child.once("error", reject);
40
+ child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
41
+ });
42
+ }
43
+
44
+ export async function runCargoGuard(args, { env = process.env, resolveCargo = resolveRealCargo, runHeavy = runHeavyCommand, runLight = spawnCargo } = {}) {
45
+ const cargo = resolveCargo({ env });
46
+ if (shouldGuardCargo(args)) return runHeavy(["--", cargo, ...args], { env });
47
+ return runLight(cargo, args, env);
48
+ }
49
+
50
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
51
+ runCargoGuard(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { console.error(`cargo-guard: ${error.message}`); process.exitCode = 1; });
52
+ }
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { cargoSubcommand, resolveRealCargo, runCargoGuard, shouldGuardCargo } from "./cargo-guard.mjs";
5
+
6
+ test("Cargo guard serializes compiling commands but bypasses inspection and formatting", () => {
7
+ for (const args of [["build"], ["test"], ["check"], ["clippy"], ["nextest", "run"], ["clean"], ["+stable", "bench"]]) {
8
+ assert.equal(shouldGuardCargo(args), true, args.join(" "));
9
+ }
10
+ for (const args of [["fmt", "--check"], ["metadata"], ["tree"], ["fetch"], ["--version"], []]) {
11
+ assert.equal(shouldGuardCargo(args), false, args.join(" "));
12
+ }
13
+ assert.equal(cargoSubcommand(["+stable", "test"]), "test");
14
+ assert.equal(shouldGuardCargo(["--color", "always", "fmt"]), false);
15
+ assert.equal(shouldGuardCargo(["--config", "net.git-fetch-with-cli=true", "check"]), true);
16
+ assert.equal(shouldGuardCargo(["-Z", "unstable-options", "fmt"]), false);
17
+ });
18
+
19
+ test("Cargo guard resolves real Cargo through rustup or explicit override", () => {
20
+ assert.equal(resolveRealCargo({ env: { RIGHTSUITE_REAL_CARGO: "/opt/toolchain/cargo" } }), "/opt/toolchain/cargo");
21
+ const calls = [];
22
+ const cargo = resolveRealCargo({ env: {}, run: (command, args) => { calls.push([command, args]); return { status: 0, stdout: "/real/cargo\n" }; } });
23
+ assert.equal(cargo, "/real/cargo");
24
+ assert.deepEqual(calls[0][1], ["which", "cargo"]);
25
+ });
26
+
27
+ test("Cargo guard routes heavy and light commands without recursion", async () => {
28
+ const calls = [];
29
+ const options = {
30
+ env: { TEST: "1" },
31
+ resolveCargo: () => "/real/cargo",
32
+ runHeavy: async (args, config) => { calls.push(["heavy", args, config.env]); return 0; },
33
+ runLight: async (command, args, env) => { calls.push(["light", command, args, env]); return 0; },
34
+ };
35
+ assert.equal(await runCargoGuard(["test"], options), 0);
36
+ assert.equal(await runCargoGuard(["fmt", "--check"], options), 0);
37
+ assert.deepEqual(calls[0], ["heavy", ["--", "/real/cargo", "test"], options.env]);
38
+ assert.deepEqual(calls[1], ["light", "/real/cargo", ["fmt", "--check"], options.env]);
39
+ });
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env node
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { randomUUID } from "node:crypto";
8
+
9
+ const GB = 1024 ** 3;
10
+ const SELF = fileURLToPath(import.meta.url);
11
+
12
+ export function heavyWorkRoot({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
13
+ if (env.RIGHTSUITE_HEAVY_WORK_ROOT) return path.resolve(env.RIGHTSUITE_HEAVY_WORK_ROOT);
14
+ if (platform === "win32" || platform === "win") {
15
+ if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is required for the RightSuite heavy-work guard");
16
+ return path.win32.resolve(env.LOCALAPPDATA, "RightSuite", "heavy-work");
17
+ }
18
+ if (platform === "darwin" || platform === "mac") return path.join(home, "Library", "Caches", "RightSuite", "heavy-work");
19
+ return path.resolve(env.XDG_CACHE_HOME || path.join(home, ".cache"), "rightsuite", "heavy-work");
20
+ }
21
+
22
+ export function heavyCommandEnvironment(env = process.env) {
23
+ return {
24
+ ...env,
25
+ CARGO_BUILD_JOBS: env.CARGO_BUILD_JOBS || env.BUILD_GUARD_CARGO_JOBS || "2",
26
+ RUST_TEST_THREADS: env.RUST_TEST_THREADS || env.BUILD_GUARD_TEST_THREADS || "2",
27
+ RIGHTSUITE_HEAVY_WORK_OWNER: "1",
28
+ };
29
+ }
30
+
31
+ export function heavyChildDetached({ slot, platform = process.platform } = {}) {
32
+ return Boolean(slot) && platform !== "win32" && platform !== "win";
33
+ }
34
+
35
+ export function parseMacResourceSnapshot({ memory = "", swap = "", thermal = "" } = {}) {
36
+ const freePercent = Number(memory.match(/memory free percentage:\s*(\d+(?:\.\d+)?)%/i)?.[1]);
37
+ const swapMatch = swap.match(/total\s*=\s*([\d.]+)M\s+used\s*=\s*([\d.]+)M/i);
38
+ const speedLimit = Number(thermal.match(/CPU_Speed_Limit\s*=\s*(\d+)/i)?.[1]);
39
+ const warning = Number(thermal.match(/(?:thermal|performance) warning level\s*[:=]\s*(\d+)/i)?.[1]);
40
+ return {
41
+ freePercent: Number.isFinite(freePercent) ? freePercent : null,
42
+ swapTotalBytes: swapMatch ? Number(swapMatch[1]) * 1024 ** 2 : null,
43
+ swapUsedBytes: swapMatch ? Number(swapMatch[2]) * 1024 ** 2 : null,
44
+ thermalLimited: Number.isFinite(speedLimit) ? speedLimit < 100 : Number.isFinite(warning) ? warning > 0 : false,
45
+ };
46
+ }
47
+
48
+ export function parseWindowsResourceSnapshot({ system = "", fallbackTotalBytes = os.totalmem(), fallbackFreeBytes = os.freemem() } = {}) {
49
+ let parsed = {};
50
+ try { parsed = JSON.parse(String(system).trim().replace(/^\uFEFF/, "")); } catch { /* use Node memory fallback */ }
51
+ const totalMemoryBytes = Number(parsed.TotalBytes) > 0 ? Number(parsed.TotalBytes) : Number(fallbackTotalBytes);
52
+ const freeMemoryBytes = Number(parsed.FreeBytes) >= 0 ? Number(parsed.FreeBytes) : Number(fallbackFreeBytes);
53
+ const cpuLoadPercent = parsed.CpuLoadPercent === null || parsed.CpuLoadPercent === undefined ? null : Number(parsed.CpuLoadPercent);
54
+ return {
55
+ freePercent: totalMemoryBytes > 0 ? Math.round((freeMemoryBytes / totalMemoryBytes) * 1000) / 10 : null,
56
+ totalMemoryBytes,
57
+ freeMemoryBytes,
58
+ cpuLoadPercent: Number.isFinite(cpuLoadPercent) ? cpuLoadPercent : null,
59
+ thermalLimited: false,
60
+ };
61
+ }
62
+
63
+ export function systemResourceSnapshot({ platform = process.platform, run = spawnSync, totalmem = os.totalmem, freemem = os.freemem } = {}) {
64
+ if (platform === "win32" || platform === "win") {
65
+ const script = "$os=Get-CimInstance Win32_OperatingSystem;$cpu=(Get-CimInstance Win32_Processor|Measure-Object LoadPercentage -Average).Average;[pscustomobject]@{TotalBytes=[double]$os.TotalVisibleMemorySize*1024;FreeBytes=[double]$os.FreePhysicalMemory*1024;CpuLoadPercent=[double]$cpu}|ConvertTo-Json -Compress";
66
+ const result = run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true });
67
+ return { platform, ...parseWindowsResourceSnapshot({ system: result.status === 0 ? result.stdout : "", fallbackTotalBytes: totalmem(), fallbackFreeBytes: freemem() }) };
68
+ }
69
+ if (platform !== "darwin" && platform !== "mac") return { platform, freePercent: null, thermalLimited: false };
70
+ const output = (command, args) => {
71
+ const result = run(command, args, { encoding: "utf8", windowsHide: true });
72
+ return result.status === 0 ? String(result.stdout ?? result.stderr ?? "") : "";
73
+ };
74
+ return {
75
+ platform,
76
+ ...parseMacResourceSnapshot({
77
+ memory: output("memory_pressure", ["-Q"]),
78
+ swap: output("sysctl", ["vm.swapusage"]),
79
+ thermal: output("pmset", ["-g", "therm"]),
80
+ }),
81
+ };
82
+ }
83
+
84
+ export function resourceBlockers(snapshot, { minFreePercent = 15, maxCpuLoadPercent = 90 } = {}) {
85
+ const blockers = [];
86
+ if (snapshot.freePercent !== null && snapshot.freePercent < minFreePercent) {
87
+ blockers.push(`memory free ${snapshot.freePercent}% < ${minFreePercent}%`);
88
+ }
89
+ if (Number.isFinite(snapshot.cpuLoadPercent) && snapshot.cpuLoadPercent > maxCpuLoadPercent) {
90
+ blockers.push(`CPU load ${snapshot.cpuLoadPercent}% > ${maxCpuLoadPercent}%`);
91
+ }
92
+ if (snapshot.thermalLimited) blockers.push("macOS reports thermal/performance limiting");
93
+ return blockers;
94
+ }
95
+
96
+ export function formatResourceSnapshot(snapshot) {
97
+ const fields = [];
98
+ if (snapshot.freePercent !== null) fields.push(`memory-free=${snapshot.freePercent}%`);
99
+ if (Number.isFinite(snapshot.swapUsedBytes) && Number.isFinite(snapshot.swapTotalBytes)) {
100
+ fields.push(`swap=${(snapshot.swapUsedBytes / GB).toFixed(1)}/${(snapshot.swapTotalBytes / GB).toFixed(1)}GiB`);
101
+ }
102
+ if (Number.isFinite(snapshot.cpuLoadPercent)) fields.push(`cpu-load=${snapshot.cpuLoadPercent}%`);
103
+ if (snapshot.platform === "darwin" || snapshot.platform === "mac" || snapshot.thermalLimited) {
104
+ fields.push(`thermal=${snapshot.thermalLimited ? "limited" : "ok"}`);
105
+ }
106
+ return fields.join(" ");
107
+ }
108
+
109
+ function processAlive(pid) {
110
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
111
+ try { process.kill(Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; }
112
+ }
113
+
114
+ function processGroupAlive(pid) {
115
+ try { process.kill(-Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; }
116
+ }
117
+
118
+ function sleep(ms) {
119
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
120
+ }
121
+
122
+ function readOwner(lockDir) {
123
+ try {
124
+ const owner = JSON.parse(readFileSync(path.join(lockDir, "owner.json"), "utf8"));
125
+ try { return { ...owner, ...JSON.parse(readFileSync(path.join(lockDir, "child.json"), "utf8")) }; } catch { return owner; }
126
+ } catch { return null; }
127
+ }
128
+
129
+ function writeTrackedChild(lockDir, owner, child) {
130
+ const file = path.join(lockDir, "child.json");
131
+ const temporary = path.join(lockDir, `.child-${owner.token}.tmp`);
132
+ writeFileSync(temporary, `${JSON.stringify(child)}\n`, { mode: 0o600 });
133
+ renameSync(temporary, file);
134
+ }
135
+
136
+ export function processStartedAt(pid, { platform = process.platform, run = spawnSync } = {}) {
137
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null;
138
+ const result = platform === "win32" || platform === "win"
139
+ ? run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${Number(pid)} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`], { encoding: "utf8", windowsHide: true })
140
+ : run("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", windowsHide: true });
141
+ if (result.status !== 0) return null;
142
+ const value = Date.parse(String(result.stdout ?? "").trim());
143
+ return Number.isFinite(value) ? value : null;
144
+ }
145
+
146
+ export function terminateProcessTree(pid, { platform = process.platform, run = spawnSync, kill = process.kill, treeAlive = processGroupAlive, pause = sleep } = {}) {
147
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return;
148
+ if (platform === "win32" || platform === "win") {
149
+ run("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
150
+ return;
151
+ }
152
+ try { kill(-Number(pid), "SIGTERM"); } catch { try { kill(Number(pid), "SIGTERM"); } catch { return; } }
153
+ pause(500);
154
+ if (treeAlive(pid)) { try { kill(-Number(pid), "SIGKILL"); } catch { try { kill(Number(pid), "SIGKILL"); } catch { /* gone */ } } }
155
+ }
156
+
157
+ export async function watchOwnedProcessTree(
158
+ { root, token, ownerPid, childPid, childStartedAtMs },
159
+ { alive = processAlive, startedAt = processStartedAt, terminate = terminateProcessTree, pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {},
160
+ ) {
161
+ const lockDir = path.join(root, "slot");
162
+ while (alive(ownerPid)) {
163
+ if (readOwner(lockDir)?.token !== token) return false;
164
+ await pause(500);
165
+ }
166
+ const owner = readOwner(lockDir);
167
+ if (owner?.token !== token || Number(owner.childPid) !== Number(childPid)) return false;
168
+ if (alive(childPid)) {
169
+ const observedStart = startedAt(childPid);
170
+ if (!Number.isFinite(observedStart) || Math.abs(observedStart - Number(childStartedAtMs)) > 2000) {
171
+ throw new Error(`watcher found reused child pid ${childPid}; refusing termination`);
172
+ }
173
+ terminate(childPid);
174
+ }
175
+ releaseOwned(lockDir, token);
176
+ return true;
177
+ }
178
+
179
+ function spawnOwnerWatcher(details) {
180
+ const payload = Buffer.from(JSON.stringify(details)).toString("base64url");
181
+ const watcher = spawn(process.execPath, [SELF, "--watch-owner-base64", payload], {
182
+ detached: true, env: process.env, stdio: "ignore", windowsHide: true,
183
+ });
184
+ watcher.once("error", () => {
185
+ const observedStart = processStartedAt(details.childPid);
186
+ if (Number.isFinite(observedStart) && Math.abs(observedStart - Number(details.childStartedAtMs)) <= 2000) {
187
+ terminateProcessTree(details.childPid);
188
+ }
189
+ });
190
+ watcher.unref();
191
+ }
192
+
193
+ export function acquireHeavyWorkSlot({
194
+ root = heavyWorkRoot(), pid = process.pid, argv = process.argv, waitMs = Number(process.env.BUILD_GUARD_WAIT_SECS ?? 900) * 1000,
195
+ pollMs = 1000, alive = processAlive, pause = sleep, snapshot = systemResourceSnapshot,
196
+ minFreePercent = Number(process.env.BUILD_GUARD_MIN_MEMORY_FREE_PERCENT ?? 15), log = (message) => console.error(`[heavy-work] ${message}`),
197
+ maxCpuLoadPercent = Number(process.env.BUILD_GUARD_MAX_CPU_LOAD_PERCENT ?? 90),
198
+ processStartedAt: startedAt = processStartedAt, terminate = terminateProcessTree, startWatcher = spawnOwnerWatcher,
199
+ } = {}) {
200
+ mkdirSync(root, { recursive: true });
201
+ const lockDir = path.join(root, "slot");
202
+ const token = randomUUID();
203
+ const started = Date.now();
204
+ let lastReport = 0;
205
+ for (;;) {
206
+ try {
207
+ mkdirSync(lockDir);
208
+ const fd = openSync(path.join(lockDir, "owner.json"), "wx");
209
+ writeFileSync(fd, `${JSON.stringify({ schema: 1, pid: Number(pid), token, argv: [...argv], createdAt: new Date().toISOString() })}\n`);
210
+ closeSync(fd);
211
+ break;
212
+ } catch (error) {
213
+ if (error.code !== "EEXIST") throw error;
214
+ const owner = readOwner(lockDir);
215
+ const incompleteAge = (() => { try { return Date.now() - statSync(lockDir).mtimeMs; } catch { return 0; } })();
216
+ if (owner ? !alive(owner.pid) : incompleteAge > 10_000) {
217
+ if (owner?.childPid && alive(owner.childPid)) {
218
+ const observedStart = startedAt(owner.childPid);
219
+ if (!Number.isFinite(observedStart) || Math.abs(observedStart - Number(owner.childStartedAtMs)) > 2000) {
220
+ throw new Error(`dead heavy-work owner pid ${owner.pid}; tracked child pid ${owner.childPid} identity changed; refusing to kill or admit`);
221
+ }
222
+ log(`reaping owned process tree pid ${owner.childPid} after owner pid ${owner.pid} exited`);
223
+ terminate(owner.childPid);
224
+ }
225
+ rmSync(lockDir, { recursive: true, force: true });
226
+ continue;
227
+ }
228
+ if (Date.now() - started >= waitMs) throw new Error(`heavy-work slot timed out after ${waitMs}ms; holder pid ${owner?.pid ?? "starting"}`);
229
+ if (Date.now() - lastReport >= 10_000) { log(`waiting for pid ${owner?.pid ?? "starting"}`); lastReport = Date.now(); }
230
+ pause(Math.min(pollMs, Math.max(1, waitMs - (Date.now() - started))));
231
+ }
232
+ }
233
+
234
+ for (;;) {
235
+ const current = snapshot();
236
+ const blockers = resourceBlockers(current, { minFreePercent, maxCpuLoadPercent });
237
+ if (blockers.length === 0) {
238
+ log(`admitted ${formatResourceSnapshot(current)} jobs=${heavyCommandEnvironment().CARGO_BUILD_JOBS}`);
239
+ break;
240
+ }
241
+ if (Date.now() - started >= waitMs) {
242
+ releaseOwned(lockDir, token);
243
+ throw new Error(`resource admission timed out: ${blockers.join(", ")}`);
244
+ }
245
+ if (Date.now() - lastReport >= 10_000) { log(`resource wait: ${blockers.join(", ")}`); lastReport = Date.now(); }
246
+ pause(Math.min(5000, Math.max(1, waitMs - (Date.now() - started))));
247
+ }
248
+ return {
249
+ root,
250
+ trackChild(childPid, childCommand = "") {
251
+ const owner = readOwner(lockDir);
252
+ if (owner?.token !== token) throw new Error("heavy-work slot ownership changed before child tracking");
253
+ const childStartedAtMs = startedAt(childPid);
254
+ if (!Number.isFinite(childStartedAtMs)) {
255
+ if (!alive(childPid)) return false;
256
+ throw new Error(`heavy-work could not verify child pid ${childPid} start time`);
257
+ }
258
+ writeTrackedChild(lockDir, owner, { childPid: Number(childPid), childCommand: String(childCommand), childStartedAtMs });
259
+ startWatcher({ root, token, ownerPid: Number(pid), childPid: Number(childPid), childStartedAtMs });
260
+ return true;
261
+ },
262
+ release: () => releaseOwned(lockDir, token),
263
+ };
264
+ }
265
+
266
+ function releaseOwned(lockDir, token) {
267
+ const owner = readOwner(lockDir);
268
+ if (owner?.token === token) rmSync(lockDir, { recursive: true, force: true });
269
+ }
270
+
271
+ export async function runHeavyCommand(args, { env = process.env } = {}) {
272
+ let command;
273
+ let commandArgs;
274
+ if (args[0] === "--shell-base64") {
275
+ const source = Buffer.from(args[1] || "", "base64").toString("utf8");
276
+ const shell = process.platform === "win32" ? (env.SHELL || "bash") : (env.SHELL || "/bin/zsh");
277
+ command = shell; commandArgs = ["-lc", source];
278
+ } else {
279
+ const separator = args.indexOf("--");
280
+ const values = separator >= 0 ? args.slice(separator + 1) : args;
281
+ [command, ...commandArgs] = values;
282
+ }
283
+ if (!command) throw new Error("heavy-command: expected -- <command> or --shell-base64 <value>");
284
+ if (env.RIGHTSUITE_HEAVY_WORK_OWNER === "1") return spawnAttached(command, commandArgs, env);
285
+ const slot = acquireHeavyWorkSlot();
286
+ try { return await spawnAttached(command, commandArgs, heavyCommandEnvironment(env), slot); } finally { slot.release(); }
287
+ }
288
+
289
+ function spawnAttached(command, args, env, slot) {
290
+ return new Promise((resolve, reject) => {
291
+ const child = spawn(command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true, detached: heavyChildDetached({ slot }) });
292
+ try {
293
+ if (child.pid && slot) slot.trackChild(child.pid, command);
294
+ } catch (error) {
295
+ child.once("error", () => {});
296
+ if (child.pid) terminateProcessTree(child.pid);
297
+ reject(error);
298
+ return;
299
+ }
300
+ const handlers = new Map(["SIGINT", "SIGTERM", "SIGHUP"].map((signal) => [signal, () => {
301
+ if (child.pid) terminateProcessTree(child.pid);
302
+ process.exitCode = signal === "SIGINT" ? 130 : 143;
303
+ }]));
304
+ for (const [signal, handler] of handlers) process.once(signal, handler);
305
+ const cleanup = () => { for (const [signal, handler] of handlers) process.removeListener(signal, handler); };
306
+ child.once("error", (error) => { cleanup(); reject(error); });
307
+ child.once("exit", (code, signal) => { cleanup(); resolve(code ?? (signal ? 1 : 0)); });
308
+ });
309
+ }
310
+
311
+ if (process.argv[1] && path.resolve(process.argv[1]) === SELF) {
312
+ const args = process.argv.slice(2);
313
+ const task = args[0] === "--watch-owner-base64"
314
+ ? watchOwnedProcessTree(JSON.parse(Buffer.from(args[1] || "", "base64url").toString("utf8"))).then(() => 0)
315
+ : runHeavyCommand(args);
316
+ task.then((code) => { process.exitCode = code; }).catch((error) => { console.error(`heavy-command: ${error.message}`); process.exitCode = 1; });
317
+ }
@@ -0,0 +1,164 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+
8
+ import {
9
+ acquireHeavyWorkSlot,
10
+ formatResourceSnapshot,
11
+ heavyChildDetached,
12
+ heavyCommandEnvironment,
13
+ heavyWorkRoot,
14
+ parseMacResourceSnapshot,
15
+ parseWindowsResourceSnapshot,
16
+ resourceBlockers,
17
+ runHeavyCommand,
18
+ systemResourceSnapshot,
19
+ terminateProcessTree,
20
+ watchOwnedProcessTree,
21
+ } from "./heavy-command.mjs";
22
+
23
+ test("heavy-work root is machine-wide rather than repository-local", () => {
24
+ assert.equal(heavyWorkRoot({ platform: "mac", home: "/Users/test", env: {} }), "/Users/test/Library/Caches/RightSuite/heavy-work");
25
+ assert.equal(heavyWorkRoot({ platform: "win", env: { LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" } }), "C:\\Users\\test\\AppData\\Local\\RightSuite\\heavy-work");
26
+ });
27
+
28
+ test("heavy commands default compiler and test concurrency to two", () => {
29
+ const env = heavyCommandEnvironment({});
30
+ assert.equal(env.CARGO_BUILD_JOBS, "2");
31
+ assert.equal(env.RUST_TEST_THREADS, "2");
32
+ assert.equal(env.RIGHTSUITE_HEAVY_WORK_OWNER, "1");
33
+ assert.equal(heavyCommandEnvironment({ CARGO_BUILD_JOBS: "3", RUST_TEST_THREADS: "1" }).CARGO_BUILD_JOBS, "3");
34
+ });
35
+
36
+ test("only the slot owner creates a new POSIX process group", () => {
37
+ assert.equal(heavyChildDetached({ slot: {}, platform: "mac" }), true);
38
+ assert.equal(heavyChildDetached({ slot: null, platform: "mac" }), false);
39
+ assert.equal(heavyChildDetached({ slot: {}, platform: "win" }), false);
40
+ });
41
+
42
+ test("mac resource parser reports memory, swap and thermal limiting", () => {
43
+ const snapshot = parseMacResourceSnapshot({
44
+ memory: "System-wide memory free percentage: 9%",
45
+ swap: "vm.swapusage: total = 8192.00M used = 7750.62M free = 441.38M",
46
+ thermal: "CPU_Speed_Limit = 70",
47
+ });
48
+ assert.equal(snapshot.freePercent, 9);
49
+ assert.equal(Math.round(snapshot.swapUsedBytes / 1024 ** 2), 7751);
50
+ assert.equal(snapshot.thermalLimited, true);
51
+ assert.deepEqual(resourceBlockers(snapshot), ["memory free 9% < 15%", "macOS reports thermal/performance limiting"]);
52
+ });
53
+
54
+ test("Windows admission uses physical memory and CPU load with a Node memory fallback", () => {
55
+ const snapshot = parseWindowsResourceSnapshot({
56
+ system: JSON.stringify({ TotalBytes: 16 * 1024 ** 3, FreeBytes: 4 * 1024 ** 3, CpuLoadPercent: 94 }),
57
+ });
58
+ assert.equal(snapshot.freePercent, 25);
59
+ assert.equal(snapshot.cpuLoadPercent, 94);
60
+ assert.deepEqual(resourceBlockers(snapshot), ["CPU load 94% > 90%"]);
61
+
62
+ const fallback = parseWindowsResourceSnapshot({ system: "not-json", fallbackTotalBytes: 8 * 1024 ** 3, fallbackFreeBytes: 2 * 1024 ** 3 });
63
+ assert.equal(fallback.freePercent, 25);
64
+ assert.equal(fallback.cpuLoadPercent, null);
65
+ });
66
+
67
+ test("Windows resource snapshot queries PowerShell without requiring optional modules", () => {
68
+ const calls = [];
69
+ const snapshot = systemResourceSnapshot({
70
+ platform: "win",
71
+ run: (command, args) => { calls.push({ command, args }); return { status: 0, stdout: '{"TotalBytes":8589934592,"FreeBytes":1073741824,"CpuLoadPercent":42}' }; },
72
+ totalmem: () => 1,
73
+ freemem: () => 1,
74
+ });
75
+ assert.equal(calls[0].command, "powershell.exe");
76
+ assert.deepEqual(calls[0].args.slice(0, 3), ["-NoLogo", "-NoProfile", "-NonInteractive"]);
77
+ assert.equal(snapshot.freePercent, 12.5);
78
+ assert.equal(snapshot.cpuLoadPercent, 42);
79
+ assert.equal(snapshot.thermalLimited, false);
80
+ assert.equal(formatResourceSnapshot(snapshot), "memory-free=12.5% cpu-load=42%");
81
+ });
82
+
83
+ test("nested heavy command preserves ownership without another slot", async () => {
84
+ const code = await runHeavyCommand(["--", process.execPath, "-e", "process.exit(0)"], { env: { ...process.env, RIGHTSUITE_HEAVY_WORK_OWNER: "1" } });
85
+ assert.equal(code, 0);
86
+ });
87
+
88
+ test("owned process-tree termination targets a POSIX group or Windows task tree", () => {
89
+ const signals = [];
90
+ terminateProcessTree(202, { platform: "mac", kill: (pid, signal) => signals.push([pid, signal]), treeAlive: () => true, pause: () => {} });
91
+ assert.deepEqual(signals, [[-202, "SIGTERM"], [-202, "SIGKILL"]]);
92
+ const calls = [];
93
+ terminateProcessTree(303, { platform: "win", run: (command, args) => calls.push([command, args]) });
94
+ assert.deepEqual(calls, [["taskkill", ["/PID", "303", "/T", "/F"]]]);
95
+ });
96
+
97
+ test("slot serializes contenders and removes stale owners", () => {
98
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-"));
99
+ const quiet = () => {};
100
+ const snapshot = () => ({ freePercent: 50, swapUsedBytes: 0, swapTotalBytes: 0, thermalLimited: false });
101
+ const first = acquireHeavyWorkSlot({ root, pid: 101, alive: (pid) => pid === 101, snapshot, log: quiet });
102
+ assert.throws(
103
+ () => acquireHeavyWorkSlot({ root, pid: 202, waitMs: 1, pollMs: 1, alive: (pid) => pid === 101, pause: () => {}, snapshot, log: quiet }),
104
+ /holder pid 101/,
105
+ );
106
+ first.release();
107
+
108
+ const lockDir = path.join(root, "slot");
109
+ mkdirSync(lockDir);
110
+ writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 303, token: "stale" }));
111
+ const replacement = acquireHeavyWorkSlot({ root, pid: 404, alive: () => false, snapshot, log: quiet });
112
+ replacement.release();
113
+ });
114
+
115
+ test("dead slot owner reaps its recorded matching child tree", () => {
116
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-owned-"));
117
+ const killed = [];
118
+ const options = {
119
+ root, snapshot: () => ({ freePercent: 50, thermalLimited: false }), log: () => {},
120
+ alive: (pid) => pid === 202,
121
+ processStartedAt: (pid) => pid === 202 ? 1234 : null,
122
+ terminate: (pid) => killed.push(pid),
123
+ startWatcher: () => {},
124
+ };
125
+ const first = acquireHeavyWorkSlot({ ...options, pid: 101 });
126
+ first.trackChild(202, "cargo");
127
+ assert.equal(JSON.parse(readFileSync(path.join(root, "slot", "child.json"), "utf8")).childPid, 202);
128
+ const replacement = acquireHeavyWorkSlot({ ...options, pid: 404 });
129
+ assert.deepEqual(killed, [202]);
130
+ replacement.release();
131
+ });
132
+
133
+ test("dead slot owner refuses to kill a reused child PID", () => {
134
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-reused-"));
135
+ let observedStart = 1234;
136
+ const options = {
137
+ root, snapshot: () => ({ freePercent: 50, thermalLimited: false }), log: () => {},
138
+ alive: (pid) => pid === 202,
139
+ processStartedAt: (pid) => pid === 202 ? observedStart : null,
140
+ terminate: () => assert.fail("reused PID must not be terminated"),
141
+ startWatcher: () => {},
142
+ };
143
+ const first = acquireHeavyWorkSlot({ ...options, pid: 101 });
144
+ first.trackChild(202, "cargo");
145
+ observedStart = 5678;
146
+ assert.throws(() => acquireHeavyWorkSlot({ ...options, pid: 404 }), /identity changed/);
147
+ first.release();
148
+ });
149
+
150
+ test("detached watcher reaps an owned child as soon as its owner disappears", async () => {
151
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-watch-"));
152
+ const lockDir = path.join(root, "slot");
153
+ mkdirSync(lockDir);
154
+ writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 101, token: "owned" }));
155
+ writeFileSync(path.join(lockDir, "child.json"), JSON.stringify({ childPid: 202, childStartedAtMs: 1234 }));
156
+ const killed = [];
157
+ const result = await watchOwnedProcessTree(
158
+ { root, token: "owned", ownerPid: 101, childPid: 202, childStartedAtMs: 1234 },
159
+ { alive: (pid) => pid === 202, startedAt: () => 1234, terminate: (pid) => killed.push(pid), pause: async () => {} },
160
+ );
161
+ assert.equal(result, true);
162
+ assert.deepEqual(killed, [202]);
163
+ assert.equal(existsSync(lockDir), false);
164
+ });
package/model-promote.mjs CHANGED
@@ -4,7 +4,7 @@ import { createReadStream } from "node:fs";
4
4
  import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { spawn } from "node:child_process";
7
+ import { spawn, spawnSync } from "node:child_process";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
 
10
10
  import {
@@ -13,6 +13,7 @@ import {
13
13
  } from "./runtime-artifact-manifest.mjs";
14
14
  import { loadReleaseToken } from "./release-token.mjs";
15
15
  import { registerRightAppsRelease } from "./rightapps-register.mjs";
16
+ import { assertCleanSource } from "./source-gate.mjs";
16
17
 
17
18
  export const RUNTIME_ARTIFACT_TRUSTED_KEY_ID = "rightkit-runtime-artifacts-2026-07";
18
19
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
@@ -24,6 +25,8 @@ export async function promoteRuntimeArtifact(input, deps = {}) {
24
25
  const register = deps.register;
25
26
  const { config } = input;
26
27
  assertPromotionConfig(config);
28
+ if (!input.verification) throw new Error("release source identity is required");
29
+ assertCleanSource({ status: input.verification.status, commandId: "right-release model promote" });
27
30
  if (input.authority !== "heardright") throw new Error("model promotion authority must be heardright");
28
31
  if (input.keyId !== RUNTIME_ARTIFACT_TRUSTED_KEY_ID) throw new Error(`untrusted runtime artifact key id: ${input.keyId}`);
29
32
  if (!input.privateKey) throw new Error("runtime artifact signing key is required");
@@ -134,6 +137,7 @@ async function main() {
134
137
  evidenceFile: path.resolve(configRoot, raw.evidence.file),
135
138
  config,
136
139
  dryRun: args.dryRun,
140
+ verification: readVerificationEvidence(args, configRoot),
137
141
  }, runtime);
138
142
  const manifest = result.envelope.manifest;
139
143
  console.log(`${args.dryRun ? "[dry-run] " : ""}runtime artifact promotion validated`);
@@ -161,6 +165,9 @@ Options:
161
165
  --signing-key-file <pem> Override the protected PEM path
162
166
  --key-id <id> Must equal ${RUNTIME_ARTIFACT_TRUSTED_KEY_ID}
163
167
 
168
+ Promotion runs only from a clean checkout, so the promoted artifact binds to an
169
+ exact commit.
170
+
164
171
  Signing key inputs (never printed): RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY or
165
172
  RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE. Windows defaults to
166
173
  %APPDATA%/RightKit/runtime-artifact-signing-key.pem. Live promotion also uses
@@ -187,6 +194,19 @@ function parseArgs(args) {
187
194
  return { authority, config, signingKeyFile, keyId, dryRun };
188
195
  }
189
196
 
197
+ function readVerificationEvidence(args, cwd) {
198
+ return {
199
+ candidateCommit: git(cwd, ["rev-parse", "HEAD"]),
200
+ status: git(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
201
+ };
202
+ }
203
+
204
+ function git(cwd, args) {
205
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
206
+ if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed`);
207
+ return result.stdout.trim();
208
+ }
209
+
190
210
  async function fileMetadata(file) {
191
211
  const info = await stat(file);
192
212
  if (!info.isFile() || info.size <= 0) throw new Error(`promotion input is not a nonempty file: ${file}`);
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { createHash, generateKeyPairSync } from "node:crypto";
2
+ import { createHash, generateKeyPairSync, sign } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
5
5
  import os from "node:os";
@@ -14,11 +14,17 @@ import {
14
14
  promoteRuntimeArtifact,
15
15
  runtimeArtifactUploadEnv,
16
16
  } from "./model-promote.mjs";
17
-
18
17
  const { privateKey } = generateKeyPairSync("ed25519");
19
18
  const artifactDigest = "a".repeat(64);
20
19
  const evidenceDigest = "b".repeat(64);
21
20
  const packageRoot = path.dirname(fileURLToPath(import.meta.url));
21
+ const candidateCommit = "a".repeat(40);
22
+
23
+ // Source identity as build/upload/promote read it: the exact commit, plus the
24
+ // porcelain status that must be empty for the promotion to run.
25
+ function verificationFixture(commit = candidateCommit, status = "") {
26
+ return { candidateCommit: commit, status };
27
+ }
22
28
 
23
29
  function request(overrides = {}) {
24
30
  return {
@@ -59,6 +65,7 @@ function request(overrides = {}) {
59
65
  },
60
66
  },
61
67
  },
68
+ verification: verificationFixture(),
62
69
  ...overrides,
63
70
  };
64
71
  }
@@ -92,6 +99,25 @@ test("dry-run validates and signs without invoking process or network boundaries
92
99
  assert.equal(result.envelope.signature.algorithm, "Ed25519");
93
100
  });
94
101
 
102
+ test("model promotion refuses a dirty working tree", async () => {
103
+ const events = [];
104
+ const verification = verificationFixture(candidateCommit, " M packages/release/model-promote.mjs\0");
105
+ await assert.rejects(
106
+ promoteRuntimeArtifact(request({ verification, dryRun: true }), boundaries(events)),
107
+ /clean working tree/,
108
+ );
109
+ assert.deepEqual(events, []);
110
+ });
111
+
112
+ test("model promotion requires source identity", async () => {
113
+ const events = [];
114
+ await assert.rejects(
115
+ promoteRuntimeArtifact(request({ verification: undefined, dryRun: true }), boundaries(events)),
116
+ /source identity is required/,
117
+ );
118
+ assert.deepEqual(events, []);
119
+ });
120
+
95
121
  test("never registers or publishes a pointer after immutable upload failure", async () => {
96
122
  const events = [];
97
123
  const deps = boundaries(events);
@@ -169,7 +195,7 @@ test("model promote help documents the portable config and secure key inputs", (
169
195
  assert.match(result.stdout, /RIGHTAPPS_RELEASE_TOKEN/);
170
196
  });
171
197
 
172
- test("portable CLI dry-run validates real files and signs without network mutation or secret output", async () => {
198
+ test("portable CLI promotes from a clean checkout, refuses a dirty one, and rejects injected trust flags", async () => {
173
199
  const root = await mkdtemp(path.join(os.tmpdir(), "rightkit-model-promote-test-"));
174
200
  try {
175
201
  const artifact = Buffer.from("tiny model fixture");
@@ -181,6 +207,10 @@ test("portable CLI dry-run validates real files and signs without network mutati
181
207
  writeFile(path.join(root, "evidence.json"), evidence),
182
208
  writeFile(path.join(root, "key.pem"), keyPem, { mode: 0o600 }),
183
209
  ]);
210
+ spawnSync("git", ["init"], { cwd: root, encoding: "utf8" });
211
+ spawnSync("git", ["add", "."], { cwd: root, encoding: "utf8" });
212
+ const committed = spawnSync("git", ["-c", "user.name=fixture", "-c", "user.email=fixture@example.invalid", "commit", "-m", "fixture"], { cwd: root, encoding: "utf8" });
213
+ assert.equal(committed.status, 0, committed.stderr);
184
214
  const config = {
185
215
  artifact: {
186
216
  file: "model.onnx",
@@ -195,17 +225,31 @@ test("portable CLI dry-run validates real files and signs without network mutati
195
225
  manifest: request().config.manifest,
196
226
  };
197
227
  await writeFile(path.join(root, "promotion.json"), JSON.stringify(config));
228
+ spawnSync("git", ["add", "."], { cwd: root, encoding: "utf8" });
229
+ spawnSync("git", ["-c", "user.name=fixture", "-c", "user.email=fixture@example.invalid", "commit", "-m", "config"], { cwd: root, encoding: "utf8" });
198
230
 
199
- const result = spawnSync(process.execPath, [
231
+ const promoteArgs = [
200
232
  path.join(packageRoot, "cli/right-release.mjs"), "model", "promote",
201
233
  "--authority", "heardright",
202
234
  "--config", path.join(root, "promotion.json"),
203
235
  "--signing-key-file", path.join(root, "key.pem"),
204
236
  "--dry-run",
205
- ], { cwd: root, encoding: "utf8", windowsHide: true });
237
+ ];
238
+ const result = spawnSync(process.execPath, promoteArgs, { cwd: root, encoding: "utf8", windowsHide: true });
206
239
  assert.equal(result.status, 0, result.stderr);
207
- assert.match(result.stdout, /\[dry-run\] runtime artifact promotion validated/);
208
- assert.match(result.stdout, /upload immutable object -> register signed envelope -> replace stable pointer/);
240
+
241
+ await writeFile(path.join(root, "untracked.txt"), "dirty");
242
+ const dirty = spawnSync(process.execPath, promoteArgs, { cwd: root, encoding: "utf8", windowsHide: true });
243
+ assert.equal(dirty.status, 1);
244
+ assert.match(dirty.stderr, /clean working tree/);
245
+
246
+ const attacker = spawnSync(process.execPath, [
247
+ path.join(packageRoot, "cli/right-release.mjs"), "model", "promote",
248
+ "--authority", "heardright", "--config", path.join(root, "promotion.json"),
249
+ "--verification-public-key", path.join(root, "verification-public.pem"),
250
+ ], { cwd: root, encoding: "utf8", windowsHide: true });
251
+ assert.equal(attacker.status, 1);
252
+ assert.match(attacker.stderr, /unknown model promote argument: --verification-public-key/);
209
253
  assert.doesNotMatch(`${result.stdout}${result.stderr}`, /BEGIN PRIVATE KEY/);
210
254
  assert.doesNotMatch(`${result.stdout}${result.stderr}`, /Uploading|POST https:/);
211
255
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.49",
3
+ "version": "0.2.51",
4
4
  "description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,16 @@ function canonical(file) {
16
16
 
17
17
  export function assertPrimaryReleaseCheckout(repoRoot) {
18
18
  const worktrees = git(repoRoot, ["worktree", "list", "--porcelain"]);
19
- const primary = worktrees.match(/^worktree (.+)$/m)?.[1];
19
+ const gitDir = git(repoRoot, ["rev-parse", "--git-dir"]).trim();
20
+ const configured = spawnSync("git", ["config", "--path", "--get", "core.worktree"], {
21
+ cwd: repoRoot, encoding: "utf8", windowsHide: true,
22
+ });
23
+ const primary = resolvePrimaryWorktree({
24
+ repoRoot,
25
+ worktrees,
26
+ gitDir,
27
+ coreWorktree: configured.status === 0 ? configured.stdout.trim() : "",
28
+ });
20
29
  if (!primary) throw new Error("unable to resolve the primary Git worktree");
21
30
  if (canonical(repoRoot) !== canonical(primary)) {
22
31
  throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
@@ -25,6 +34,16 @@ export function assertPrimaryReleaseCheckout(repoRoot) {
25
34
  if (branch.status !== 0) throw new Error("right-release requires a branch-attached primary Git checkout; detached HEAD is forbidden");
26
35
  }
27
36
 
37
+ export function resolvePrimaryWorktree({ repoRoot, worktrees, gitDir, coreWorktree }) {
38
+ const listed = worktrees.match(/^worktree (.+)$/m)?.[1];
39
+ if (!listed) return undefined;
40
+ const resolvedGitDir = path.resolve(repoRoot, gitDir);
41
+ if (path.resolve(listed) === resolvedGitDir && coreWorktree) {
42
+ return path.resolve(resolvedGitDir, coreWorktree);
43
+ }
44
+ return listed;
45
+ }
46
+
28
47
  export function resolveConfiguredBuildInputs(config, target, label = "release config") {
29
48
  const configured = target?.buildInputs ?? config?.buildInputs;
30
49
  if (!Array.isArray(configured?.include) || configured.include.length === 0) {
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { resolvePrimaryWorktree } from "./release-invocation.mjs";
4
+
5
+ test("resolves a submodule primary worktree from core.worktree", () => {
6
+ assert.equal(resolvePrimaryWorktree({
7
+ repoRoot: "/suite/membrane",
8
+ worktrees: "worktree /suite/.git/modules/membrane\nHEAD abc\n",
9
+ gitDir: "/suite/.git/modules/membrane",
10
+ coreWorktree: "../../../membrane",
11
+ }), "/suite/membrane");
12
+ });
13
+
14
+ test("preserves an ordinary primary worktree", () => {
15
+ assert.equal(resolvePrimaryWorktree({
16
+ repoRoot: "/suite/app",
17
+ worktrees: "worktree /suite/app\nHEAD abc\n",
18
+ gitDir: "/suite/app/.git",
19
+ coreWorktree: "",
20
+ }), "/suite/app");
21
+ });
@@ -36,6 +36,13 @@ const apps = [
36
36
  { key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs"] },
37
37
  ];
38
38
 
39
+ // ScreenRight is intentionally a macOS-only AppKit consumer. It has no Tauri
40
+ // manifest or Windows target, so keep it in an explicit registry lane rather
41
+ // than manufacturing unsupported platform entries.
42
+ const macOnlyApps = [
43
+ { key: "screenright", root: "tools/screenright", releaseFiles: ["scripts/release-package.mjs"] },
44
+ ];
45
+
39
46
  function assertReleasePackageScripts(scripts, label) {
40
47
  for (const platform of ["mac", "win"]) {
41
48
  assert.equal(scripts[`release:build:${platform}`], `right-release build --platform ${platform}`, `${label} must use the tier-neutral ${platform} build entry point`);
@@ -442,10 +449,10 @@ test("RightKit exposes one current version manifest", () => {
442
449
  "@rightkit/legal": "0.3.0",
443
450
  "@rightkit/legal-ui": "0.1.0",
444
451
  "@rightkit/license": "0.1.6",
445
- "@rightkit/release": "0.2.49",
452
+ "@rightkit/release": "0.2.51",
446
453
  });
447
454
  assert.deepEqual(versions.legacyNpm, {
448
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46"],
455
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50"],
449
456
  });
450
457
  assert.ok(
451
458
  new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
@@ -608,6 +615,45 @@ for (const app of apps) {
608
615
  });
609
616
  }
610
617
 
618
+ for (const app of macOnlyApps) {
619
+ test(`${app.key} follows the macOS-only AppKit Right Release contract`, async () => {
620
+ const root = path.join(workspace, app.root);
621
+ const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
622
+ assert.equal(pkg.scripts["release:doctor"], "right-release doctor");
623
+ assert.equal(pkg.scripts["release:build:mac"], "right-release build --platform mac");
624
+ assert.equal(pkg.scripts["release:upload:patch:mac"], "right-release upload --platform mac --tier patch");
625
+ assert.equal(pkg.scripts["release:build:win"], undefined, `${app.key} must not fake a Windows target`);
626
+ assert.equal(pkg.scripts["release:upload:patch:win"], undefined, `${app.key} must not fake a Windows target`);
627
+ assert.equal(pkg.scripts["release:upload:update:mac"], undefined, `${app.key} must not expose ungated feature-update upload`);
628
+ assert.equal(pkg.scripts["release:upload:update:win"], undefined);
629
+
630
+ const config = (await import(`${pathToFileURL(path.join(root, "right-release.config.mjs"))}?mac-only=${Date.now()}`)).default;
631
+ assert.equal(config.app, app.key);
632
+ assert.deepEqual(Object.keys(config.targets), ["mac"]);
633
+ assert.equal(config.targets.mac.signed, true);
634
+ assert.deepEqual(config.targets.mac.publish, {
635
+ cmd: "right-release",
636
+ args: ["publish-update", "--config", "right-release.config.mjs", "--platform", "mac"],
637
+ });
638
+ assert.equal(config.targets.mac.updater.artifacts.length, 1);
639
+ const updater = config.targets.mac.updater.artifacts[0];
640
+ assert.equal(updater.platform, "darwin-aarch64");
641
+ assert.match(updater.file, /\.dmg$/);
642
+ assert.equal(updater.signature, `${updater.file}.sig`);
643
+ assert.equal(updater.key, `${app.key}/updates/mac/current/ScreenRight.dmg`);
644
+ assertBuildInputs(config, config.targets.mac, `${app.key} mac`);
645
+ assertMacPackageEntry(config.targets.mac.package, pkg.scripts, app.key);
646
+ assert.ok(config.targets.mac.installer.artifacts.length >= 1);
647
+ assert.ok(config.checks.includes("legal:check"), `${app.key} must gate generated legal notices`);
648
+ assert.deepEqual(config.deps, { workdirs: ["."] });
649
+ assert.equal(existsSync(path.join(root, "src-tauri", "tauri.conf.json")), false, `${app.key} must not invent a Tauri manifest`);
650
+ for (const releaseFile of app.releaseFiles) {
651
+ const source = readFileSync(path.join(root, releaseFile), "utf8");
652
+ assert.match(source, /mirror-root-artifact/);
653
+ }
654
+ });
655
+ }
656
+
611
657
  test("HeardRight and ScrapeRight expose one locked ASR promotion adapter contract", async () => {
612
658
  const heard = (await import(`${pathToFileURL(path.join(workspace, "heardright/tauri-app-next/right-release.config.mjs"))}?asr=${Date.now()}`)).default;
613
659
  const scrape = (await import(`${pathToFileURL(path.join(workspace, "scraperight/right-release.config.mjs"))}?asr=${Date.now()}`)).default;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema": 1,
3
- "packageManager": "pnpm@11.17.0",
3
+ "packageManager": "pnpm@11.18.0",
4
4
  "npm": {
5
5
  "@rightkit/legal": "0.2.0",
6
6
  "@rightkit/license": "0.1.5",
@@ -15,10 +15,10 @@
15
15
  "@rightkit/legal": "0.3.0",
16
16
  "@rightkit/legal-ui": "0.1.0",
17
17
  "@rightkit/license": "0.1.6",
18
- "@rightkit/release": "0.2.49"
18
+ "@rightkit/release": "0.2.51"
19
19
  },
20
20
  "legacyNpm": {
21
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46"]
21
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50"]
22
22
  },
23
23
  "cargo": {
24
24
  "rightkit-license": "0.1.2",
@@ -0,0 +1,19 @@
1
+ // Release source gate.
2
+ //
3
+ // A build or upload may only run from a clean checkout, so every sealed artifact
4
+ // binds to an exact commit that exists in history rather than to whatever happened
5
+ // to be in the working tree. Upload separately re-checks that the sealed manifest's
6
+ // commit still matches HEAD, so the pair pins source identity end to end.
7
+ //
8
+ // This replaces the r7 verification-ticket / admin-broker layer removed in 578d7ac5.
9
+ // That layer defended against a hostile local admin, which is not a threat here; the
10
+ // real risk is shipping stale, dirty, or untested source. Distribution trust still
11
+ // comes from OS code signing, notarization, and the Tauri updater key — untouched.
12
+
13
+ export function assertCleanSource({ status, commandId = "release" }) {
14
+ if (typeof status !== "string") throw new Error("release source status is required");
15
+ if (status.length !== 0) {
16
+ const files = status.split("\0").filter(Boolean).map((entry) => entry.slice(3)).slice(0, 10);
17
+ throw new Error(`${commandId} requires a clean working tree; commit or stash first:\n${files.map((file) => `- ${file}`).join("\n")}`);
18
+ }
19
+ }
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { assertCleanSource } from "./source-gate.mjs";
5
+
6
+ test("a clean status passes the gate", () => {
7
+ assert.doesNotThrow(() => assertCleanSource({ status: "", commandId: "right-release build" }));
8
+ });
9
+
10
+ test("a dirty status names the command and the offending files", () => {
11
+ assert.throws(
12
+ () => assertCleanSource({ status: " M src/main.rs\0?? notes.txt\0", commandId: "right-release build" }),
13
+ (error) => {
14
+ assert.match(error.message, /right-release build requires a clean working tree/);
15
+ assert.match(error.message, /- src\/main\.rs/);
16
+ assert.match(error.message, /- notes\.txt/);
17
+ return true;
18
+ },
19
+ );
20
+ });
21
+
22
+ test("a missing status is refused rather than treated as clean", () => {
23
+ assert.throws(() => assertCleanSource({ commandId: "right-release upload" }), /status is required/);
24
+ assert.throws(() => assertCleanSource({ status: null }), /status is required/);
25
+ });
@@ -16,9 +16,9 @@ test("standalone verifier clones, installs, doctors from a nested app root, and
16
16
  const fixtureRoot = mkdtempSync(path.join(tmpdir(), "rightkit-standalone-fixture-"));
17
17
  t.after(() => rmSync(fixtureRoot, { recursive: true, force: true }));
18
18
  const source = path.join(fixtureRoot, "source");
19
- const outsideSentinel = path.join(fixtureRoot, "keep.txt");
19
+ const outsideForge = path.join(fixtureRoot, "keep.txt");
20
20
  mkdirSync(path.join(source, "apps", "desktop"), { recursive: true });
21
- writeFileSync(outsideSentinel, "keep", "utf8");
21
+ writeFileSync(outsideForge, "keep", "utf8");
22
22
  writeFileSync(path.join(source, "apps", "desktop", "package.json"), JSON.stringify({
23
23
  name: "standalone-fixture",
24
24
  private: true,
@@ -44,7 +44,7 @@ test("standalone verifier clones, installs, doctors from a nested app root, and
44
44
  assert.equal(result.apps[0].doctor.status, 0);
45
45
  assert.match(result.apps[0].doctor.stdout, /fixture doctor passed/);
46
46
  assert.equal(existsSync(result.workRoot), false, "generated clone tree must be removed");
47
- assert.equal(readFileSync(outsideSentinel, "utf8"), "keep", "cleanup must not escape the generated tree");
47
+ assert.equal(readFileSync(outsideForge, "utf8"), "keep", "cleanup must not escape the generated tree");
48
48
  assert.deepEqual(JSON.parse(readFileSync(evidencePath, "utf8")).apps.map(({ key }) => key), ["fixture"]);
49
49
  });
50
50
 
@@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
8
8
  import { runUploadStateMachine, verifySealedRelease } from "./release-state.mjs";
9
+ import { assertCleanSource } from "./source-gate.mjs";
9
10
 
10
11
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
11
12
  const PUBLISH_UPDATE = path.join(TOOL_ROOT, "publish-update.mjs");
@@ -36,9 +37,15 @@ if (platform !== "win" && platform !== "mac") fail("--platform must be win or ma
36
37
 
37
38
  const repoRoot = git(process.cwd(), ["rev-parse", "--show-toplevel"]);
38
39
  assertPrimaryReleaseCheckout(repoRoot);
40
+ const candidateCommit = git(repoRoot, ["rev-parse", "HEAD"]);
41
+ assertCleanSource({
42
+ status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
43
+ commandId: "right-release upload",
44
+ });
39
45
  const platformDir = platform === "win" ? "windows" : "mac";
40
46
  const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir);
41
47
  const sealed = verifySealedRelease(sealedDir);
48
+ if (sealed.manifest.commit !== candidateCommit) fail("sealed release was built from a different commit than the current checkout");
42
49
  const stateRoot = path.join(repoRoot, ".right-release", "state", releaseId, platformDir);
43
50
  const backupRoot = path.join(stateRoot, "rollback", tier);
44
51
  const verifiedMarker = path.join(stateRoot, `verified-${tier}.json`);
@@ -212,7 +219,7 @@ function writeJson(file, value) {
212
219
  }
213
220
 
214
221
  function usage(code) {
215
- console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update");
222
+ console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--dry-run]");
216
223
  process.exit(code);
217
224
  }
218
225