@rightkit/release 0.2.49 → 0.2.50

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 } 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) {
@@ -7,15 +7,20 @@ 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/);
15
19
  assert.match(source, /acquireCacheLease/);
16
20
  assert.match(source, /markCacheEntrySuccessful/);
17
21
  assert.match(source, /RIGHT_RELEASE_CACHE_OWNER/);
18
22
  assert.match(source, /suiteSlot\?\.release\(\)/);
23
+ assert.match(source, /heavySlot\?\.release\(\)/);
19
24
  assert.match(source, /cacheLease\?\.release\(\)/);
20
25
  assert.doesNotMatch(source, /process\.exit\(signal/);
21
26
  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,199 @@
1
+ #!/usr/bin/env node
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, 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
+
11
+ export function heavyWorkRoot({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
12
+ if (env.RIGHTSUITE_HEAVY_WORK_ROOT) return path.resolve(env.RIGHTSUITE_HEAVY_WORK_ROOT);
13
+ if (platform === "win32" || platform === "win") {
14
+ if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is required for the RightSuite heavy-work guard");
15
+ return path.win32.resolve(env.LOCALAPPDATA, "RightSuite", "heavy-work");
16
+ }
17
+ if (platform === "darwin" || platform === "mac") return path.join(home, "Library", "Caches", "RightSuite", "heavy-work");
18
+ return path.resolve(env.XDG_CACHE_HOME || path.join(home, ".cache"), "rightsuite", "heavy-work");
19
+ }
20
+
21
+ export function heavyCommandEnvironment(env = process.env) {
22
+ return {
23
+ ...env,
24
+ CARGO_BUILD_JOBS: env.CARGO_BUILD_JOBS || env.BUILD_GUARD_CARGO_JOBS || "2",
25
+ RUST_TEST_THREADS: env.RUST_TEST_THREADS || env.BUILD_GUARD_TEST_THREADS || "2",
26
+ RIGHTSUITE_HEAVY_WORK_OWNER: "1",
27
+ };
28
+ }
29
+
30
+ export function parseMacResourceSnapshot({ memory = "", swap = "", thermal = "" } = {}) {
31
+ const freePercent = Number(memory.match(/memory free percentage:\s*(\d+(?:\.\d+)?)%/i)?.[1]);
32
+ const swapMatch = swap.match(/total\s*=\s*([\d.]+)M\s+used\s*=\s*([\d.]+)M/i);
33
+ const speedLimit = Number(thermal.match(/CPU_Speed_Limit\s*=\s*(\d+)/i)?.[1]);
34
+ const warning = Number(thermal.match(/(?:thermal|performance) warning level\s*[:=]\s*(\d+)/i)?.[1]);
35
+ return {
36
+ freePercent: Number.isFinite(freePercent) ? freePercent : null,
37
+ swapTotalBytes: swapMatch ? Number(swapMatch[1]) * 1024 ** 2 : null,
38
+ swapUsedBytes: swapMatch ? Number(swapMatch[2]) * 1024 ** 2 : null,
39
+ thermalLimited: Number.isFinite(speedLimit) ? speedLimit < 100 : Number.isFinite(warning) ? warning > 0 : false,
40
+ };
41
+ }
42
+
43
+ export function parseWindowsResourceSnapshot({ system = "", fallbackTotalBytes = os.totalmem(), fallbackFreeBytes = os.freemem() } = {}) {
44
+ let parsed = {};
45
+ try { parsed = JSON.parse(String(system).trim().replace(/^\uFEFF/, "")); } catch { /* use Node memory fallback */ }
46
+ const totalMemoryBytes = Number(parsed.TotalBytes) > 0 ? Number(parsed.TotalBytes) : Number(fallbackTotalBytes);
47
+ const freeMemoryBytes = Number(parsed.FreeBytes) >= 0 ? Number(parsed.FreeBytes) : Number(fallbackFreeBytes);
48
+ const cpuLoadPercent = parsed.CpuLoadPercent === null || parsed.CpuLoadPercent === undefined ? null : Number(parsed.CpuLoadPercent);
49
+ return {
50
+ freePercent: totalMemoryBytes > 0 ? Math.round((freeMemoryBytes / totalMemoryBytes) * 1000) / 10 : null,
51
+ totalMemoryBytes,
52
+ freeMemoryBytes,
53
+ cpuLoadPercent: Number.isFinite(cpuLoadPercent) ? cpuLoadPercent : null,
54
+ thermalLimited: false,
55
+ };
56
+ }
57
+
58
+ export function systemResourceSnapshot({ platform = process.platform, run = spawnSync, totalmem = os.totalmem, freemem = os.freemem } = {}) {
59
+ if (platform === "win32" || platform === "win") {
60
+ 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";
61
+ const result = run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true });
62
+ return { platform, ...parseWindowsResourceSnapshot({ system: result.status === 0 ? result.stdout : "", fallbackTotalBytes: totalmem(), fallbackFreeBytes: freemem() }) };
63
+ }
64
+ if (platform !== "darwin" && platform !== "mac") return { platform, freePercent: null, thermalLimited: false };
65
+ const output = (command, args) => {
66
+ const result = run(command, args, { encoding: "utf8", windowsHide: true });
67
+ return result.status === 0 ? String(result.stdout ?? result.stderr ?? "") : "";
68
+ };
69
+ return {
70
+ platform,
71
+ ...parseMacResourceSnapshot({
72
+ memory: output("memory_pressure", ["-Q"]),
73
+ swap: output("sysctl", ["vm.swapusage"]),
74
+ thermal: output("pmset", ["-g", "therm"]),
75
+ }),
76
+ };
77
+ }
78
+
79
+ export function resourceBlockers(snapshot, { minFreePercent = 15, maxCpuLoadPercent = 90 } = {}) {
80
+ const blockers = [];
81
+ if (snapshot.freePercent !== null && snapshot.freePercent < minFreePercent) {
82
+ blockers.push(`memory free ${snapshot.freePercent}% < ${minFreePercent}%`);
83
+ }
84
+ if (Number.isFinite(snapshot.cpuLoadPercent) && snapshot.cpuLoadPercent > maxCpuLoadPercent) {
85
+ blockers.push(`CPU load ${snapshot.cpuLoadPercent}% > ${maxCpuLoadPercent}%`);
86
+ }
87
+ if (snapshot.thermalLimited) blockers.push("macOS reports thermal/performance limiting");
88
+ return blockers;
89
+ }
90
+
91
+ export function formatResourceSnapshot(snapshot) {
92
+ const fields = [];
93
+ if (snapshot.freePercent !== null) fields.push(`memory-free=${snapshot.freePercent}%`);
94
+ if (Number.isFinite(snapshot.swapUsedBytes) && Number.isFinite(snapshot.swapTotalBytes)) {
95
+ fields.push(`swap=${(snapshot.swapUsedBytes / GB).toFixed(1)}/${(snapshot.swapTotalBytes / GB).toFixed(1)}GiB`);
96
+ }
97
+ if (Number.isFinite(snapshot.cpuLoadPercent)) fields.push(`cpu-load=${snapshot.cpuLoadPercent}%`);
98
+ if (snapshot.platform === "darwin" || snapshot.platform === "mac" || snapshot.thermalLimited) {
99
+ fields.push(`thermal=${snapshot.thermalLimited ? "limited" : "ok"}`);
100
+ }
101
+ return fields.join(" ");
102
+ }
103
+
104
+ function processAlive(pid) {
105
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
106
+ try { process.kill(Number(pid), 0); return true; } catch { return false; }
107
+ }
108
+
109
+ function sleep(ms) {
110
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
111
+ }
112
+
113
+ function readOwner(lockDir) {
114
+ try { return JSON.parse(readFileSync(path.join(lockDir, "owner.json"), "utf8")); } catch { return null; }
115
+ }
116
+
117
+ export function acquireHeavyWorkSlot({
118
+ root = heavyWorkRoot(), pid = process.pid, argv = process.argv, waitMs = Number(process.env.BUILD_GUARD_WAIT_SECS ?? 900) * 1000,
119
+ pollMs = 1000, alive = processAlive, pause = sleep, snapshot = systemResourceSnapshot,
120
+ minFreePercent = Number(process.env.BUILD_GUARD_MIN_MEMORY_FREE_PERCENT ?? 15), log = (message) => console.error(`[heavy-work] ${message}`),
121
+ maxCpuLoadPercent = Number(process.env.BUILD_GUARD_MAX_CPU_LOAD_PERCENT ?? 90),
122
+ } = {}) {
123
+ mkdirSync(root, { recursive: true });
124
+ const lockDir = path.join(root, "slot");
125
+ const token = randomUUID();
126
+ const started = Date.now();
127
+ let lastReport = 0;
128
+ for (;;) {
129
+ try {
130
+ mkdirSync(lockDir);
131
+ const fd = openSync(path.join(lockDir, "owner.json"), "wx");
132
+ writeFileSync(fd, `${JSON.stringify({ schema: 1, pid: Number(pid), token, argv: [...argv], createdAt: new Date().toISOString() })}\n`);
133
+ closeSync(fd);
134
+ break;
135
+ } catch (error) {
136
+ if (error.code !== "EEXIST") throw error;
137
+ const owner = readOwner(lockDir);
138
+ const incompleteAge = (() => { try { return Date.now() - statSync(lockDir).mtimeMs; } catch { return 0; } })();
139
+ if (owner ? !alive(owner.pid) : incompleteAge > 10_000) {
140
+ rmSync(lockDir, { recursive: true, force: true });
141
+ continue;
142
+ }
143
+ if (Date.now() - started >= waitMs) throw new Error(`heavy-work slot timed out after ${waitMs}ms; holder pid ${owner?.pid ?? "starting"}`);
144
+ if (Date.now() - lastReport >= 10_000) { log(`waiting for pid ${owner?.pid ?? "starting"}`); lastReport = Date.now(); }
145
+ pause(Math.min(pollMs, Math.max(1, waitMs - (Date.now() - started))));
146
+ }
147
+ }
148
+
149
+ for (;;) {
150
+ const current = snapshot();
151
+ const blockers = resourceBlockers(current, { minFreePercent, maxCpuLoadPercent });
152
+ if (blockers.length === 0) {
153
+ log(`admitted ${formatResourceSnapshot(current)} jobs=${heavyCommandEnvironment().CARGO_BUILD_JOBS}`);
154
+ break;
155
+ }
156
+ if (Date.now() - started >= waitMs) {
157
+ releaseOwned(lockDir, token);
158
+ throw new Error(`resource admission timed out: ${blockers.join(", ")}`);
159
+ }
160
+ if (Date.now() - lastReport >= 10_000) { log(`resource wait: ${blockers.join(", ")}`); lastReport = Date.now(); }
161
+ pause(Math.min(5000, Math.max(1, waitMs - (Date.now() - started))));
162
+ }
163
+ return { root, release: () => releaseOwned(lockDir, token) };
164
+ }
165
+
166
+ function releaseOwned(lockDir, token) {
167
+ const owner = readOwner(lockDir);
168
+ if (owner?.token === token) rmSync(lockDir, { recursive: true, force: true });
169
+ }
170
+
171
+ export async function runHeavyCommand(args, { env = process.env } = {}) {
172
+ let command;
173
+ let commandArgs;
174
+ if (args[0] === "--shell-base64") {
175
+ const source = Buffer.from(args[1] || "", "base64").toString("utf8");
176
+ const shell = process.platform === "win32" ? (env.SHELL || "bash") : (env.SHELL || "/bin/zsh");
177
+ command = shell; commandArgs = ["-lc", source];
178
+ } else {
179
+ const separator = args.indexOf("--");
180
+ const values = separator >= 0 ? args.slice(separator + 1) : args;
181
+ [command, ...commandArgs] = values;
182
+ }
183
+ if (!command) throw new Error("heavy-command: expected -- <command> or --shell-base64 <value>");
184
+ if (env.RIGHTSUITE_HEAVY_WORK_OWNER === "1") return spawnAttached(command, commandArgs, env);
185
+ const slot = acquireHeavyWorkSlot();
186
+ try { return await spawnAttached(command, commandArgs, heavyCommandEnvironment(env)); } finally { slot.release(); }
187
+ }
188
+
189
+ function spawnAttached(command, args, env) {
190
+ return new Promise((resolve, reject) => {
191
+ const child = spawn(command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true });
192
+ child.once("error", reject);
193
+ child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
194
+ });
195
+ }
196
+
197
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
198
+ runHeavyCommand(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { console.error(`heavy-command: ${error.message}`); process.exitCode = 1; });
199
+ }
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, 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
+ heavyCommandEnvironment,
12
+ heavyWorkRoot,
13
+ parseMacResourceSnapshot,
14
+ parseWindowsResourceSnapshot,
15
+ resourceBlockers,
16
+ runHeavyCommand,
17
+ systemResourceSnapshot,
18
+ } from "./heavy-command.mjs";
19
+
20
+ test("heavy-work root is machine-wide rather than repository-local", () => {
21
+ assert.equal(heavyWorkRoot({ platform: "mac", home: "/Users/test", env: {} }), "/Users/test/Library/Caches/RightSuite/heavy-work");
22
+ assert.equal(heavyWorkRoot({ platform: "win", env: { LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" } }), "C:\\Users\\test\\AppData\\Local\\RightSuite\\heavy-work");
23
+ });
24
+
25
+ test("heavy commands default compiler and test concurrency to two", () => {
26
+ const env = heavyCommandEnvironment({});
27
+ assert.equal(env.CARGO_BUILD_JOBS, "2");
28
+ assert.equal(env.RUST_TEST_THREADS, "2");
29
+ assert.equal(env.RIGHTSUITE_HEAVY_WORK_OWNER, "1");
30
+ assert.equal(heavyCommandEnvironment({ CARGO_BUILD_JOBS: "3", RUST_TEST_THREADS: "1" }).CARGO_BUILD_JOBS, "3");
31
+ });
32
+
33
+ test("mac resource parser reports memory, swap and thermal limiting", () => {
34
+ const snapshot = parseMacResourceSnapshot({
35
+ memory: "System-wide memory free percentage: 9%",
36
+ swap: "vm.swapusage: total = 8192.00M used = 7750.62M free = 441.38M",
37
+ thermal: "CPU_Speed_Limit = 70",
38
+ });
39
+ assert.equal(snapshot.freePercent, 9);
40
+ assert.equal(Math.round(snapshot.swapUsedBytes / 1024 ** 2), 7751);
41
+ assert.equal(snapshot.thermalLimited, true);
42
+ assert.deepEqual(resourceBlockers(snapshot), ["memory free 9% < 15%", "macOS reports thermal/performance limiting"]);
43
+ });
44
+
45
+ test("Windows admission uses physical memory and CPU load with a Node memory fallback", () => {
46
+ const snapshot = parseWindowsResourceSnapshot({
47
+ system: JSON.stringify({ TotalBytes: 16 * 1024 ** 3, FreeBytes: 4 * 1024 ** 3, CpuLoadPercent: 94 }),
48
+ });
49
+ assert.equal(snapshot.freePercent, 25);
50
+ assert.equal(snapshot.cpuLoadPercent, 94);
51
+ assert.deepEqual(resourceBlockers(snapshot), ["CPU load 94% > 90%"]);
52
+
53
+ const fallback = parseWindowsResourceSnapshot({ system: "not-json", fallbackTotalBytes: 8 * 1024 ** 3, fallbackFreeBytes: 2 * 1024 ** 3 });
54
+ assert.equal(fallback.freePercent, 25);
55
+ assert.equal(fallback.cpuLoadPercent, null);
56
+ });
57
+
58
+ test("Windows resource snapshot queries PowerShell without requiring optional modules", () => {
59
+ const calls = [];
60
+ const snapshot = systemResourceSnapshot({
61
+ platform: "win",
62
+ run: (command, args) => { calls.push({ command, args }); return { status: 0, stdout: '{"TotalBytes":8589934592,"FreeBytes":1073741824,"CpuLoadPercent":42}' }; },
63
+ totalmem: () => 1,
64
+ freemem: () => 1,
65
+ });
66
+ assert.equal(calls[0].command, "powershell.exe");
67
+ assert.deepEqual(calls[0].args.slice(0, 3), ["-NoLogo", "-NoProfile", "-NonInteractive"]);
68
+ assert.equal(snapshot.freePercent, 12.5);
69
+ assert.equal(snapshot.cpuLoadPercent, 42);
70
+ assert.equal(snapshot.thermalLimited, false);
71
+ assert.equal(formatResourceSnapshot(snapshot), "memory-free=12.5% cpu-load=42%");
72
+ });
73
+
74
+ test("nested heavy command preserves ownership without another slot", async () => {
75
+ const code = await runHeavyCommand(["--", process.execPath, "-e", "process.exit(0)"], { env: { ...process.env, RIGHTSUITE_HEAVY_WORK_OWNER: "1" } });
76
+ assert.equal(code, 0);
77
+ });
78
+
79
+ test("slot serializes contenders and removes stale owners", () => {
80
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-"));
81
+ const quiet = () => {};
82
+ const snapshot = () => ({ freePercent: 50, swapUsedBytes: 0, swapTotalBytes: 0, thermalLimited: false });
83
+ const first = acquireHeavyWorkSlot({ root, pid: 101, alive: (pid) => pid === 101, snapshot, log: quiet });
84
+ assert.throws(
85
+ () => acquireHeavyWorkSlot({ root, pid: 202, waitMs: 1, pollMs: 1, alive: (pid) => pid === 101, pause: () => {}, snapshot, log: quiet }),
86
+ /holder pid 101/,
87
+ );
88
+ first.release();
89
+
90
+ const lockDir = path.join(root, "slot");
91
+ mkdirSync(lockDir);
92
+ writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 303, token: "stale" }));
93
+ const replacement = acquireHeavyWorkSlot({ root, pid: 404, alive: () => false, snapshot, log: quiet });
94
+ replacement.release();
95
+ });
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.50",
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": {
@@ -442,10 +442,10 @@ test("RightKit exposes one current version manifest", () => {
442
442
  "@rightkit/legal": "0.3.0",
443
443
  "@rightkit/legal-ui": "0.1.0",
444
444
  "@rightkit/license": "0.1.6",
445
- "@rightkit/release": "0.2.49",
445
+ "@rightkit/release": "0.2.50",
446
446
  });
447
447
  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"],
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", "0.2.49"],
449
449
  });
450
450
  assert.ok(
451
451
  new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
@@ -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.50"
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"]
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