@rightkit/release 0.2.38 → 0.2.39

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
@@ -21,7 +21,8 @@ import path from "node:path";
21
21
  import { spawn, spawnSync } from "node:child_process";
22
22
  import { fileURLToPath, pathToFileURL } from "node:url";
23
23
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
24
- import { cacheFingerprint, commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, watchProgress } from "./release-state.mjs";
24
+ import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
25
+ import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
25
26
 
26
27
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
27
28
  const WORKER = path.join(TOOL_ROOT, "release.mjs");
@@ -50,16 +51,20 @@ const vaultRoot = resolveReleaseLayout({ repoRoot, configPath: path.resolve(conf
50
51
  mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
51
52
  const lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
52
53
  let child = null;
54
+ let suiteSlot;
55
+ let cacheLease;
56
+ let interrupted;
53
57
 
54
58
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
55
59
  process.once(signal, () => {
56
60
  if (child?.pid) killTree(child.pid);
57
- lock.release();
58
- process.exit(signal === "SIGINT" ? 130 : 143);
61
+ interrupted = new Error(`release interrupted by ${signal}`);
62
+ process.exitCode = signal === "SIGINT" ? 130 : 143;
59
63
  });
60
64
  }
61
65
 
62
66
  try {
67
+ throwIfInterrupted();
63
68
  if (!dryRun) git(repoRoot, ["fetch", "origin", "main"]);
64
69
  const commit = git(repoRoot, ["rev-parse", dryRun ? "HEAD" : "origin/main"]);
65
70
  const shortCommit = commit.slice(0, 8);
@@ -83,27 +88,35 @@ try {
83
88
  const target = config.targets?.[platform];
84
89
  if (!target?.package) fail(`${config.app} has no ${platform} package command`);
85
90
  const dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, invocationRoot, relativeConfig, target.buildInputs ?? config.buildInputs), commit);
86
- if (dirtyInputs.length > 0) {
87
- fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
88
- }
91
+ if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
89
92
  const requiredInputs = inputPaths(appRoot, worktreeConfigPath, target);
90
93
  const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
91
94
  const inputHashes = hashInputs(requiredInputs);
92
95
  const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
93
96
  const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
94
- const cacheKey = cacheFingerprint({
95
- cargoLockSha256: cargoLock ? inputHashes[path.relative(worktree, cargoLock)] : "none",
96
- rustc: toolVersions.rustc,
97
- target: toolVersions.rustHost,
98
- features: cargoToml ? sqlCipherFeatures(readFileSync(cargoToml, "utf8")) : [],
99
- });
97
+ const cacheIdentity = resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
98
+ const cacheKey = cacheIdentity.fingerprint;
99
+ const cacheMode = process.env.RIGHT_RELEASE_CACHE_MODE ?? (platform === "mac" ? "shared" : "legacy");
100
+ if (cacheMode !== "legacy" && cacheMode !== "shared") fail("RIGHT_RELEASE_CACHE_MODE must be legacy or shared");
101
+ const sharedCacheRoot = cacheMode === "shared" ? resolveSharedCacheRoot({ platform, env: process.env }) : undefined;
102
+ const sharedLayout = cacheMode === "shared"
103
+ ? resolveCacheLayout({ cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, fingerprint: cacheKey, kind: "release" })
104
+ : undefined;
105
+ if (cacheMode === "shared") assertSccacheVersion(toolVersions.sccache);
100
106
  const env = {
101
107
  ...process.env,
102
- ...releaseEnvironment({ root: vaultRoot, platform, cacheKey, kind: "release" }),
108
+ ...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
103
109
  RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
104
110
  RIGHT_RELEASE_APP_ROOT: layout.appRoot,
105
111
  };
106
- if (!commandExists("sccache")) delete env.RUSTC_WRAPPER;
112
+ if (cacheMode === "legacy") delete env.RIGHT_RELEASE_CACHE_OWNER;
113
+ if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
114
+ if (cacheMode === "legacy" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
115
+ if (cacheMode === "shared") {
116
+ suiteSlot = acquireSuiteBuildSlot({ layout: sharedLayout, pid: process.pid, argv: process.argv, waitMs: Number(process.env.BUILD_GUARD_WAIT_SECS ?? 900) * 1000 });
117
+ cacheLease = acquireCacheLease({ layout: sharedLayout, pid: process.pid, argv: process.argv });
118
+ }
119
+ throwIfInterrupted();
107
120
  const releaseId = `${config.app}-${config.version}-${shortCommit}`;
108
121
  const platformDir = platform === "win" ? "windows" : "mac";
109
122
  const buildRoot = path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
@@ -130,7 +143,15 @@ try {
130
143
  requiredInputs,
131
144
  ops: {
132
145
  preflight: async () => {
133
- assertDiskSpace(repoRoot, target.preflight?.minFreeGb ?? 20);
146
+ throwIfInterrupted();
147
+ if (cacheMode === "shared") {
148
+ const policy = readCachePolicy(process.env);
149
+ const snapshot = inspectCache({ cacheRoot: sharedCacheRoot });
150
+ const plan = planCachePrune({ snapshot, policy, protectedEntryIds: new Set([sharedLayout.id]) });
151
+ applyCachePrune({ plan, cacheRoot: sharedCacheRoot, dryRun: false });
152
+ const volumes = inspectWriteVolumes({ repoRoot, vaultRoot, cacheRoot: sharedCacheRoot });
153
+ assertWriteVolumeFloors({ volumes, policy, configuredRepoMinimumBytes: Math.max(Number(target.preflight?.minFreeGb ?? 25), 25) * 1024 ** 3 });
154
+ } else assertDiskSpace(repoRoot, target.preflight?.minFreeGb ?? 20);
134
155
  assertExecutables(["git", config.packageManager ?? "pnpm", "cargo", "rustc", ...(target.preflight?.executables ?? [])]);
135
156
  for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
136
157
  for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(appRoot, command.cwd ?? "."), env);
@@ -141,6 +162,7 @@ try {
141
162
  checkpoint(stateRoot, "preflight_complete");
142
163
  },
143
164
  prepare: async () => {
165
+ throwIfInterrupted();
144
166
  const cacheTarget = env.CARGO_TARGET_DIR;
145
167
  const targetLink = path.join(appRoot, "src-tauri", "target");
146
168
  mkdirSync(cacheTarget, { recursive: true });
@@ -151,6 +173,7 @@ try {
151
173
  await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget);
152
174
  },
153
175
  build: async () => {
176
+ throwIfInterrupted();
154
177
  await runProgress(
155
178
  process.execPath,
156
179
  [WORKER, "--config", worktreeConfigPath, "--platform", platform, "--no-upload"],
@@ -164,13 +187,22 @@ try {
164
187
  },
165
188
  seal: async ({ sealedDir }) => {
166
189
  sealRelease({ configRoot: path.dirname(worktreeConfigPath), sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey });
190
+ verifySealedRelease(sealedDir);
191
+ if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
167
192
  checkpoint(stateRoot, "sealed");
168
193
  },
169
194
  },
170
195
  });
196
+ if (cacheMode === "shared") {
197
+ const snapshot = inspectCache({ cacheRoot: sharedCacheRoot });
198
+ const plan = planCachePrune({ snapshot, policy: readCachePolicy(process.env), protectedEntryIds: new Set([sharedLayout.id]) });
199
+ applyCachePrune({ plan, cacheRoot: sharedCacheRoot, dryRun: false });
200
+ }
171
201
  console.log(`right-release build: ${result.resumed ? "resumed" : "sealed"} ${result.releaseId}`);
172
202
  console.log(`sealed: ${result.sealedDir}`);
173
203
  } finally {
204
+ cacheLease?.release();
205
+ suiteSlot?.release();
174
206
  lock.release();
175
207
  }
176
208
 
@@ -374,6 +406,11 @@ function commandExists(name) {
374
406
  return probe.status === 0;
375
407
  }
376
408
 
409
+ function assertSccacheVersion(version) {
410
+ const match = String(version ?? "").match(/sccache\s+(\d+)\.(\d+)\.(\d+)/i);
411
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 15) fail("shared cache requires sccache >= 0.15.0; install with: cargo install sccache --locked; then verify: sccache --version");
412
+ }
413
+
377
414
  function commandOutput(cmd, runArgs) {
378
415
  try {
379
416
  return commandOutputPortable(cmd, runArgs);
@@ -419,5 +456,7 @@ function usage(code) {
419
456
 
420
457
  function fail(message) {
421
458
  console.error(`right-release build: ${message}`);
422
- process.exit(1);
459
+ throw new Error(message);
423
460
  }
461
+
462
+ function throwIfInterrupted() { if (interrupted) throw interrupted; }
@@ -0,0 +1,24 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+
6
+ const source = readFileSync(path.join(path.dirname(new URL(import.meta.url).pathname), "build-release.mjs"), "utf8");
7
+
8
+ test("macOS builds default to Cache V2 while explicit legacy mode remains available", () => {
9
+ assert.match(source, /RIGHT_RELEASE_CACHE_MODE/);
10
+ assert.match(source, /platform === "mac" \? "shared" : "legacy"/);
11
+ assert.match(source, /cacheMode !== "legacy" && cacheMode !== "shared"/);
12
+ assert.match(source, /resolveSharedCacheRoot/);
13
+ assert.match(source, /acquireSuiteBuildSlot/);
14
+ assert.match(source, /acquireCacheLease/);
15
+ assert.match(source, /markCacheEntrySuccessful/);
16
+ assert.match(source, /RIGHT_RELEASE_CACHE_OWNER/);
17
+ assert.match(source, /suiteSlot\?\.release\(\)/);
18
+ assert.match(source, /cacheLease\?\.release\(\)/);
19
+ assert.doesNotMatch(source, /process\.exit\(signal/);
20
+ assert.match(source, /throwIfInterrupted/);
21
+ assert.match(source, /delete env\.RIGHT_RELEASE_CACHE_OWNER/);
22
+ assert.match(source, /assertPrimaryReleaseCheckout\(repoRoot\)/);
23
+ assert.match(source, /dirtyBuildInputs\(repoRoot/);
24
+ });
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import {
6
+ applyCachePrune,
7
+ inspectCache,
8
+ migrateLegacyCache,
9
+ planCachePrune,
10
+ readCachePolicy,
11
+ resolveCacheLayout,
12
+ resolveSharedCacheIdentity,
13
+ resolveSharedCacheRoot,
14
+ } from "./cache-policy.mjs";
15
+ import { resolveReleaseLayout } from "./release-state.mjs";
16
+
17
+ export async function runCacheCommand(args = process.argv.slice(2), { env = process.env, stdout = console.log, stderr = console.error } = {}) {
18
+ const [command, ...rest] = args;
19
+ const json = rest.includes("--json");
20
+ const platform = platformFrom(env);
21
+ const cacheRoot = resolveSharedCacheRoot({ platform, env });
22
+ if (command === "status") {
23
+ rejectUnknown(rest, new Set(["--json"]));
24
+ const snapshot = inspectCache({ cacheRoot });
25
+ return output({ schema: 1, command, ...snapshot, policy: readCachePolicy(env) }, json, stdout);
26
+ }
27
+ if (command === "prune") {
28
+ rejectUnknown(rest, new Set(["--json", "--dry-run", "--apply"]));
29
+ if (rest.includes("--dry-run") && rest.includes("--apply")) throw new Error("cache prune accepts either --dry-run or --apply");
30
+ const dryRun = !rest.includes("--apply");
31
+ const snapshot = inspectCache({ cacheRoot });
32
+ const plan = planCachePrune({ snapshot, policy: readCachePolicy(env), protectedEntryIds: new Set(snapshot.entries.filter((entry) => entry.leased).map((entry) => entry.id)) });
33
+ const result = dryRun ? { candidateIds: plan.candidateIds, removedIds: [], reclaimedBytes: 0, dryRun: true } : applyCachePrune({ plan, cacheRoot, dryRun: false });
34
+ return output({ schema: 1, command, cacheRoot, dryRun, plan, ...result }, json, stdout);
35
+ }
36
+ if (command === "migrate") {
37
+ const configPath = option(rest, "--config");
38
+ if (!configPath) throw new Error("cache migrate requires --config <right-release.config.mjs>");
39
+ rejectUnknown(rest.filter((value) => value !== "--config" && value !== configPath), new Set(["--json", "--dry-run", "--apply"]));
40
+ if (rest.includes("--dry-run") && rest.includes("--apply")) throw new Error("cache migrate accepts either --dry-run or --apply");
41
+ const configFile = path.resolve(configPath);
42
+ const config = (await import(pathToFileURL(configFile).href)).default;
43
+ if (!config?.app) throw new Error("cache migrate config must expose app");
44
+ const appRoot = path.dirname(configFile);
45
+ const configuredTarget = config.targets?.[platform] ?? {};
46
+ const lockPath = path.join(appRoot, "src-tauri", "Cargo.lock");
47
+ const identity = resolveSharedCacheIdentity({ cargoLockPath: lockPath, cargoTomlPath: path.join(appRoot, "src-tauri", "Cargo.toml"), rustcVerbose: env.RIGHT_RELEASE_RUSTC_VERBOSE, targetTriple: configuredTarget.cargoTarget ?? configuredTarget.targetTriple ?? configuredTarget.rustTarget ?? env.TAURI_ENV_TARGET_TRIPLE, profile: configuredTarget.profile ?? "release" });
48
+ const fingerprint = env.RIGHT_RELEASE_CACHE_KEY || identity.fingerprint;
49
+ const layout = resolveCacheLayout({ cacheRoot, platform, architecture: identity.architecture, app: config.app, fingerprint, kind: "release" });
50
+ const dryRun = !rest.includes("--apply");
51
+ const legacy = resolveReleaseLayout({ repoRoot: findRepoRoot(appRoot), configPath: configFile });
52
+ const legacyTargetRoot = path.join(legacy.vaultRoot, "cache", "cargo-target", platform, fingerprint);
53
+ const result = migrateLegacyCache({ legacyRoot: path.join(appRoot, "src-tauri", "target"), legacyTargetRoot, legacyCargoHome: path.join(legacy.vaultRoot, "cache", "cargo-home"), legacyReleaseLockPath: path.join(legacy.vaultRoot, "locks", `${platform}.lock.json`), layout, app: config.app, dryRun });
54
+ return output({ schema: 1, command, cacheRoot, ...result }, json, stdout);
55
+ }
56
+ throw new Error("right-release cache: expected status, prune, or migrate");
57
+ }
58
+
59
+ function output(value, json, stdout) {
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}`);
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
+ return value;
64
+ }
65
+
66
+ function option(args, name) { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; }
67
+ function rejectUnknown(args, allowed) { for (const arg of args) if (!allowed.has(arg)) throw new Error(`unknown cache argument: ${arg}`); }
68
+ function platformFrom(env) { return env.RIGHT_RELEASE_PLATFORM || (process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform); }
69
+ function findRepoRoot(start) { let current = path.resolve(start); for (;;) { if (existsSync(path.join(current, ".git"))) return current; const parent = path.dirname(current); if (parent === current) throw new Error(`cache migrate could not find Git repository for ${start}`); current = parent; } }
70
+
71
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
72
+ runCacheCommand().catch((error) => { console.error(error.message); process.exitCode = 2; });
73
+ }
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import test from "node:test";
7
+
8
+ const root = path.dirname(new URL(import.meta.url).pathname);
9
+ const cli = path.join(root, "cli", "right-release.mjs");
10
+
11
+ function run(args, cacheRoot, env = {}) {
12
+ return spawnSync(process.execPath, [cli, "cache", ...args], { encoding: "utf8", env: { ...process.env, RIGHT_RELEASE_CACHE_ROOT: cacheRoot, ...env } });
13
+ }
14
+
15
+ test("cache status has human and schema-stable JSON output", () => {
16
+ const cacheRoot = mkdtempSync(path.join(os.tmpdir(), "rightkit-cache-command-"));
17
+ const human = run(["status"], cacheRoot);
18
+ const json = run(["status", "--json"], cacheRoot);
19
+ assert.equal(human.status, 0, human.stderr);
20
+ assert.match(human.stdout, /RightKit cache root:/);
21
+ assert.equal(json.status, 0, json.stderr);
22
+ assert.equal(JSON.parse(json.stdout).schema, 1);
23
+ });
24
+
25
+ test("cache prune defaults to dry-run and only apply can mutate an admitted temporary entry", () => {
26
+ const cacheRoot = mkdtempSync(path.join(os.tmpdir(), "rightkit-cache-command-"));
27
+ const target = path.join(cacheRoot, "targets", "mac", "fixture", "1111111111111111");
28
+ mkdirSync(target, { recursive: true });
29
+ writeFileSync(path.join(target, "payload"), "payload");
30
+ writeFileSync(path.join(target, ".rightkit-cache-entry.json"), JSON.stringify({ schema: 1, id: "target:mac:fixture:1111111111111111", kind: "release", platform: "mac", architecture: "aarch64", app: "fixture", fingerprint: "1111111111111111", createdAt: "2020-01-01T00:00:00.000Z", lastUsedAt: "2020-01-01T00:00:00.000Z", lastSuccessfulBuildAt: "2020-01-01T00:00:00.000Z", toolchain: {} }));
31
+ const dry = run(["prune", "--dry-run", "--json"], cacheRoot);
32
+ assert.equal(dry.status, 0, dry.stderr);
33
+ assert.equal(JSON.parse(dry.stdout).dryRun, true);
34
+ assert.equal(run(["prune", "--apply", "--json"], cacheRoot).status, 0);
35
+ });
36
+
37
+ test("cache rejects unknown commands and migration requires a config", () => {
38
+ const cacheRoot = mkdtempSync(path.join(os.tmpdir(), "rightkit-cache-command-"));
39
+ assert.notEqual(run(["unknown"], cacheRoot).status, 0);
40
+ assert.notEqual(run(["migrate"], cacheRoot).status, 0);
41
+ });
42
+
43
+ test("status and prune dry-run do not create a cache root or lock file", () => {
44
+ const parent = mkdtempSync(path.join(os.tmpdir(), "rightkit-cache-command-"));
45
+ const cacheRoot = path.join(parent, "never-created");
46
+ assert.equal(run(["status", "--json"], cacheRoot).status, 0);
47
+ assert.equal(run(["prune", "--dry-run", "--json"], cacheRoot).status, 0);
48
+ assert.equal(existsSync(cacheRoot), false);
49
+ });
50
+
51
+ test("cache migrate uses the repository vault Cargo home, not an app-local lookalike", () => {
52
+ const sandbox = mkdtempSync(path.join(os.tmpdir(), "rightkit-cache-command-"));
53
+ const repo = path.join(sandbox, "repo"); const app = path.join(repo, "app"); const cacheRoot = path.join(sandbox, "shared-cache");
54
+ const config = path.join(app, "right-release.config.mjs"); const fingerprint = "abcdef0123456789";
55
+ const target = path.join(repo, ".right-release", "cache", "cargo-target", "mac", fingerprint);
56
+ const vaultCargoHome = path.join(repo, ".right-release", "cache", "cargo-home");
57
+ const appCargoHome = path.join(app, ".right-release", "cache", "cargo-home");
58
+ mkdirSync(path.join(repo, ".git"), { recursive: true });
59
+ mkdirSync(path.dirname(config), { recursive: true });
60
+ writeFileSync(config, "export default { app: 'fixture', targets: { mac: { targetTriple: 'aarch64-apple-darwin' } } };\n");
61
+ mkdirSync(path.join(app, "src-tauri"), { recursive: true });
62
+ writeFileSync(path.join(app, "src-tauri", "Cargo.lock"), "fixture-lock\n");
63
+ writeFileSync(path.join(app, "src-tauri", "Cargo.toml"), "[package]\nname = 'fixture'\n");
64
+ mkdirSync(target, { recursive: true }); writeFileSync(path.join(target, "artifact"), "target");
65
+ symlinkSync(target, path.join(app, "src-tauri", "target"));
66
+ mkdirSync(vaultCargoHome, { recursive: true }); writeFileSync(path.join(vaultCargoHome, "registry"), "vault");
67
+ mkdirSync(appCargoHome, { recursive: true }); writeFileSync(path.join(appCargoHome, "registry"), "app-local");
68
+ const result = run(["migrate", "--config", config, "--apply", "--json"], cacheRoot, { RIGHT_RELEASE_PLATFORM: "mac", RIGHT_RELEASE_RUSTC_VERBOSE: "rustc 1.91.0\nhost: aarch64-apple-darwin", RIGHT_RELEASE_CACHE_KEY: fingerprint });
69
+ assert.equal(result.status, 0, result.stderr);
70
+ assert.equal(JSON.parse(result.stdout).cargoHomeMoved, true);
71
+ assert.equal(readFileSync(path.join(cacheRoot, "cargo-home", "registry"), "utf8"), "vault");
72
+ assert.equal(readFileSync(path.join(appCargoHome, "registry"), "utf8"), "app-local");
73
+ });
@@ -0,0 +1,423 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ renameSync,
11
+ rmSync,
12
+ statSync,
13
+ statfsSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { createHash } from "node:crypto";
20
+ import { spawnSync } from "node:child_process";
21
+
22
+ export const CACHE_SCHEMA = 1;
23
+ export const DEFAULT_TARGET_MAX_BYTES = 12 * 1024 ** 3;
24
+ export const DEFAULT_SCCACHE_MAX_BYTES = 8 * 1024 ** 3;
25
+ export const DEFAULT_DESIRED_FREE_BYTES = 60 * 1024 ** 3;
26
+ export const DEFAULT_HARD_FREE_BYTES = 25 * 1024 ** 3;
27
+ // Any build holds release -> suite slot -> GC -> entry lease; prune holds only GC.
28
+ export const CACHE_LOCK_ORDER = ["release", "suite-build-slot", "gc", "entry-lease"];
29
+
30
+ const MARKER = ".rightkit-cache-entry.json";
31
+
32
+ export function resolveSharedCacheRoot({ platform = process.platform, env = process.env, home = os.homedir(), xdgCacheHome } = {}) {
33
+ const override = env.RIGHT_RELEASE_CACHE_ROOT;
34
+ if (override) {
35
+ if (!isAbsoluteForPlatform(override, platform)) throw new Error("RIGHT_RELEASE_CACHE_ROOT must be an absolute path");
36
+ return resolveForPlatform(override, platform);
37
+ }
38
+ if (platform === "darwin" || platform === "mac") return path.join(home, "Library", "Caches", "RightSuite", "release");
39
+ if (platform === "win32" || platform === "win") {
40
+ const local = env.LOCALAPPDATA;
41
+ if (!local || !isAbsoluteForPlatform(local, platform)) throw new Error("LOCALAPPDATA must be an absolute path for the RightSuite cache");
42
+ return path.win32.resolve(local, "RightSuite", "Cache", "release");
43
+ }
44
+ return path.resolve(xdgCacheHome || env.XDG_CACHE_HOME || path.join(home, ".cache"), "rightsuite", "release");
45
+ }
46
+
47
+ export function resolveCacheLayout({ cacheRoot, platform, architecture, app, fingerprint, kind = "release" } = {}) {
48
+ if (!cacheRoot || !path.isAbsolute(cacheRoot)) throw new Error("cacheRoot must be an absolute path");
49
+ if (kind !== "release" && kind !== "test") throw new Error(`invalid cache target kind: ${kind}`);
50
+ for (const [name, value] of Object.entries({ platform, architecture, app, fingerprint })) {
51
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(value || ""))) throw new Error(`invalid cache ${name}`);
52
+ }
53
+ const root = path.resolve(cacheRoot);
54
+ const targetRoot = path.join(root, kind === "release" ? "targets" : "test-targets");
55
+ const targetDir = path.join(targetRoot, platform, app, fingerprint);
56
+ assertLexicalInside(targetRoot, targetDir);
57
+ const id = `${kind === "release" ? "target" : "test-target"}:${platform}:${app}:${fingerprint}`;
58
+ return {
59
+ cacheRoot: root,
60
+ targetRoot,
61
+ targetDir,
62
+ markerPath: path.join(targetDir, MARKER),
63
+ cargoHome: path.join(root, "cargo-home"),
64
+ sccacheDir: path.join(root, "sccache"),
65
+ leasesDir: path.join(root, "leases"),
66
+ leasePath: path.join(root, "leases", `${encodeURIComponent(id)}.json`),
67
+ suiteSlotPath: path.join(root, "leases", "suite-build-slot.json"),
68
+ gcLockPath: path.join(root, "gc", "gc.lock.json"),
69
+ migrationDir: path.join(root, "migration"),
70
+ platform,
71
+ architecture,
72
+ app,
73
+ fingerprint,
74
+ kind,
75
+ id,
76
+ };
77
+ }
78
+
79
+ export function readCachePolicy(env = process.env) {
80
+ return {
81
+ targetMaxBytes: readPositive(env.RIGHT_RELEASE_TARGET_MAX_BYTES, DEFAULT_TARGET_MAX_BYTES),
82
+ sccacheMaxBytes: readPositive(env.RIGHT_RELEASE_SCCACHE_MAX_BYTES, DEFAULT_SCCACHE_MAX_BYTES),
83
+ desiredFreeBytes: readPositive(env.RIGHT_RELEASE_DESIRED_FREE_BYTES, DEFAULT_DESIRED_FREE_BYTES),
84
+ hardFreeBytes: readPositive(env.RIGHT_RELEASE_HARD_FREE_BYTES, DEFAULT_HARD_FREE_BYTES),
85
+ };
86
+ }
87
+
88
+ export function resolveSharedCacheIdentity({ cargoLockPath, cargoTomlPath, rustcVerbose, targetTriple, profile = "release", features } = {}) {
89
+ const rustc = rustcVerbose ?? readRustcVerbose();
90
+ const host = rustc.match(/^host:\s*(.+)$/m)?.[1];
91
+ if (!host) throw new Error("rustc -vV did not report a host target triple");
92
+ const target = targetTriple || host;
93
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(target)) throw new Error("invalid Cargo target triple");
94
+ const architecture = target.split("-")[0];
95
+ const cargoLockSha256 = cargoLockPath && existsSync(cargoLockPath) ? createHash("sha256").update(readFileSync(cargoLockPath)).digest("hex") : "none";
96
+ const nativeFeatures = features ?? cargoNativeFeatures(cargoTomlPath);
97
+ const payload = JSON.stringify({ cargoLockSha256, rustc, target, architecture, profile, features: [...nativeFeatures].sort() });
98
+ return { cargoLockSha256, rustc, target, architecture, profile, features: [...nativeFeatures].sort(), fingerprint: createHash("sha256").update(payload).digest("hex").slice(0, 16) };
99
+ }
100
+
101
+ export function ensureCacheEntry({ layout, metadata = {}, now = new Date() } = {}) {
102
+ requireLayout(layout);
103
+ const root = resolvedRoot(layout.cacheRoot);
104
+ const targetDir = safeEntryDirectory(root, layout);
105
+ mkdirSafeDescendant(root, targetDir);
106
+ assertPhysicalInside(path.join(root, layout.kind === "release" ? "targets" : "test-targets"), targetDir);
107
+ const markerPath = path.join(targetDir, MARKER);
108
+ let entry = readJson(markerPath);
109
+ if (entry && !validEntry(entry, layout)) throw new Error(`invalid cache entry marker: ${markerPath}`);
110
+ if (!entry) {
111
+ const at = iso(now);
112
+ entry = {
113
+ schema: CACHE_SCHEMA, id: layout.id, kind: layout.kind, platform: layout.platform,
114
+ architecture: layout.architecture, app: layout.app, fingerprint: layout.fingerprint,
115
+ createdAt: at, lastUsedAt: at, lastSuccessfulBuildAt: null,
116
+ toolchain: metadata.toolchain ?? { cargo: "unknown", rustc: "unknown", host: "unknown" },
117
+ };
118
+ atomicJson(markerPath, entry);
119
+ }
120
+ return entry;
121
+ }
122
+
123
+ export function acquireCacheLease({ layout, pid = process.pid, argv = process.argv, now = new Date() } = {}) {
124
+ requireLayout(layout);
125
+ const payload = { schema: CACHE_SCHEMA, id: layout.id, pid: Number(pid), argv: [...argv], createdAt: iso(now) };
126
+ const root = resolvedRoot(layout.cacheRoot);
127
+ const gcPayload = { schema: CACHE_SCHEMA, pid: Number(pid), argv: [...argv], createdAt: iso(now), order: CACHE_LOCK_ORDER };
128
+ acquireAtomicLock(path.join(root, "gc", "gc.lock.json"), gcPayload, "global GC lock", now);
129
+ try {
130
+ ensureCacheEntry({ layout, now });
131
+ acquireAtomicLock(layout.leasePath, payload, "cache entry lease", now);
132
+ touchEntry(layout, now);
133
+ return releaseFor(layout.leasePath, payload);
134
+ } finally {
135
+ releaseFor(path.join(root, "gc", "gc.lock.json"), gcPayload).release();
136
+ }
137
+ }
138
+
139
+ export function acquireSuiteBuildSlot({ layout, pid = process.pid, argv = process.argv, waitMs = 900_000, now = new Date(), sleep = defaultSleep } = {}) {
140
+ requireLayout(layout);
141
+ const started = Date.now();
142
+ const payload = { schema: CACHE_SCHEMA, pid: Number(pid), argv: [...argv], createdAt: iso(now) };
143
+ for (;;) {
144
+ try {
145
+ acquireAtomicLock(layout.suiteSlotPath, payload, "suite build slot", new Date());
146
+ return releaseFor(layout.suiteSlotPath, payload);
147
+ } catch (error) {
148
+ if (!/active/i.test(error.message)) throw error;
149
+ if (Date.now() - started >= waitMs) throw new Error(`${error.message}; suite build slot timed out after ${waitMs}ms without alternate-target fallback`);
150
+ sleep(Math.min(250, Math.max(1, waitMs - (Date.now() - started))));
151
+ }
152
+ }
153
+ }
154
+
155
+ export function markCacheEntrySuccessful({ layout, now = new Date() } = {}) {
156
+ requireLayout(layout);
157
+ const entry = readJson(layout.markerPath);
158
+ if (!validEntry(entry, layout)) throw new Error(`invalid cache entry marker: ${layout.markerPath}`);
159
+ entry.lastUsedAt = iso(now);
160
+ entry.lastSuccessfulBuildAt = iso(now);
161
+ atomicJson(layout.markerPath, entry);
162
+ return entry;
163
+ }
164
+
165
+ export function inspectCache({ cacheRoot, statfs = statfsSync, now = new Date() } = {}) {
166
+ const root = readonlyRoot(cacheRoot);
167
+ if (!root) return { cacheRoot: path.resolve(cacheRoot), entries: [], targetBytes: 0, cacheFreeBytes: 0, device: undefined, inspectedAt: iso(now) };
168
+ const entries = [];
169
+ for (const kind of ["targets", "test-targets"]) {
170
+ const kindRoot = path.join(root, kind);
171
+ if (!existsSync(kindRoot) || lstatSync(kindRoot).isSymbolicLink()) continue;
172
+ for (const platformEntry of safeDirEntries(kindRoot)) for (const appEntry of safeDirEntries(platformEntry.path)) for (const fingerprintEntry of safeDirEntries(appEntry.path)) {
173
+ const dir = fingerprintEntry.path;
174
+ const markerPath = path.join(dir, MARKER);
175
+ const marker = readJson(markerPath);
176
+ const expectedKind = kind === "targets" ? "release" : "test";
177
+ const layout = marker ? resolveCacheLayout({ cacheRoot: root, platform: marker.platform, architecture: marker.architecture, app: marker.app, fingerprint: marker.fingerprint, kind: expectedKind }) : null;
178
+ if (!layout || layout.targetDir !== dir || !validEntry(marker, layout)) continue;
179
+ const leasePath = path.join(root, "leases", `${encodeURIComponent(marker.id)}.json`);
180
+ const lease = readJson(leasePath);
181
+ const live = isLiveLease(lease, now);
182
+ entries.push({ id: marker.id, dir, marker, bytes: directoryBytes(dir), leased: live, lease: live ? lease : null });
183
+ }
184
+ }
185
+ const fs = statfs(root, { bigint: false });
186
+ 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) };
187
+ }
188
+
189
+ export function inspectWriteVolumes({ repoRoot, vaultRoot, cacheRoot, statfs = statfsSync } = {}) {
190
+ const paths = [
191
+ { role: "repo", path: path.resolve(repoRoot || vaultRoot) },
192
+ { role: "cache", path: path.resolve(cacheRoot) },
193
+ ];
194
+ const volumes = [];
195
+ for (const item of paths) {
196
+ const fs = statfs(item.path, { bigint: false });
197
+ const device = fs.dev ?? safeDevice(item.path) ?? `${fs.type}:${item.path}`;
198
+ const existing = volumes.find((volume) => String(volume.device) === String(device));
199
+ if (existing) { existing.roles.push(item.role); existing.paths.push(item.path); continue; }
200
+ volumes.push({ device, roles: [item.role], paths: [item.path], freeBytes: freeBytes(fs) });
201
+ }
202
+ return volumes;
203
+ }
204
+
205
+ export function assertWriteVolumeFloors({ volumes, policy = readCachePolicy(), configuredRepoMinimumBytes = DEFAULT_HARD_FREE_BYTES } = {}) {
206
+ for (const volume of volumes ?? []) {
207
+ const repoRequired = volume.roles.includes("repo") ? Math.max(configuredRepoMinimumBytes, DEFAULT_HARD_FREE_BYTES) : 0;
208
+ const cacheRequired = volume.roles.includes("cache") ? policy.hardFreeBytes : 0;
209
+ const required = Math.max(repoRequired, cacheRequired);
210
+ if (volume.freeBytes < required) throw new Error(`insufficient free space on ${volume.roles.join("+")} volume ${volume.device}: ${(volume.freeBytes / 1024 ** 3).toFixed(1)} GiB free, ${(required / 1024 ** 3).toFixed(1)} GiB required`);
211
+ }
212
+ return true;
213
+ }
214
+
215
+ export function planCachePrune({ snapshot, policy = readCachePolicy(), protectedEntryIds = new Set() } = {}) {
216
+ const protectedIds = new Set(protectedEntryIds);
217
+ const candidates = (snapshot.entries ?? [])
218
+ .filter((entry) => entry.marker?.kind === "release" && !entry.leased && !protectedIds.has(entry.id))
219
+ .sort((a, b) => String(a.marker.lastSuccessfulBuildAt ?? a.marker.lastUsedAt ?? a.marker.createdAt).localeCompare(String(b.marker.lastSuccessfulBuildAt ?? b.marker.lastUsedAt ?? b.marker.createdAt)) || a.id.localeCompare(b.id));
220
+ let bytes = snapshot.targetBytes ?? 0;
221
+ let free = snapshot.cacheFreeBytes ?? Infinity;
222
+ const selected = [];
223
+ for (const entry of candidates) {
224
+ if (bytes <= policy.targetMaxBytes && free >= policy.desiredFreeBytes) break;
225
+ selected.push({ id: entry.id, dir: entry.dir, bytes: entry.bytes, reason: bytes > policy.targetMaxBytes ? "target-cap" : "free-space" });
226
+ bytes -= entry.bytes; free += entry.bytes;
227
+ }
228
+ return { schema: CACHE_SCHEMA, cacheRoot: snapshot.cacheRoot, candidateIds: selected.map((entry) => entry.id), candidates: selected, estimatedReclaimedBytes: selected.reduce((sum, entry) => sum + entry.bytes, 0), targetBytesAfter: bytes, cacheFreeBytesAfter: free };
229
+ }
230
+
231
+ export function applyCachePrune({ plan, cacheRoot, dryRun = true, beforeDelete } = {}) {
232
+ const result = { candidateIds: (plan?.candidates ?? []).map((entry) => entry.id), removedIds: [], reclaimedBytes: 0, dryRun };
233
+ if (dryRun) return result;
234
+ const root = resolvedRoot(cacheRoot || plan?.cacheRoot);
235
+ const lockPath = path.join(root, "gc", "gc.lock.json");
236
+ const lockPayload = { schema: CACHE_SCHEMA, pid: process.pid, argv: process.argv, createdAt: iso(new Date()), order: CACHE_LOCK_ORDER };
237
+ acquireAtomicLock(lockPath, lockPayload, "global GC lock", new Date());
238
+ try {
239
+ for (const candidate of plan?.candidates ?? []) {
240
+ if (!safePruneCandidate(root, candidate)) continue;
241
+ beforeDelete?.(candidate);
242
+ if (!safePruneCandidate(root, candidate)) continue;
243
+ if (!dryRun) {
244
+ rmSync(realpathSync(candidate.dir), { recursive: true, force: false, maxRetries: 2 });
245
+ result.removedIds.push(candidate.id); result.reclaimedBytes += candidate.bytes ?? 0;
246
+ }
247
+ }
248
+ } finally {
249
+ releaseFor(lockPath, lockPayload).release();
250
+ }
251
+ return result;
252
+ }
253
+
254
+ export function migrateLegacyCache({ legacyRoot, legacyTargetRoot, legacyCargoHome, legacyReleaseLockPath, layout, app, dryRun = true, waitMs = 0, onPostMove, writeMigrationReceipt = atomicJson } = {}) {
255
+ requireLayout(layout);
256
+ if (app !== layout.app) throw new Error("legacy cache app must match cache layout");
257
+ const root = dryRun ? path.resolve(layout.cacheRoot) : resolvedRoot(layout.cacheRoot);
258
+ const receipt = path.join(root, "migration", `${app}.json`);
259
+ const existing = readJson(receipt);
260
+ if (existing) return { ...existing, resumed: true, dryRun };
261
+ const linkPath = path.resolve(legacyRoot);
262
+ const target = layout.targetDir;
263
+ const initial = resolveLegacyMigrationSource({ linkPath, legacyTargetRoot, layout });
264
+ const outcome = { schema: CACHE_SCHEMA, app, legacyRoot: initial.source, target, dryRun, moved: false, coldCache: false, cargoHomeMoved: false, deferredLegacyCargoHome: null, createdAt: iso(new Date()) };
265
+ if (initial.coldCache) return persistMigrationOutcome({ outcome: { ...outcome, coldCache: true }, receipt, root, dryRun });
266
+ if (pathEntryExists(target)) throw new Error(`cache migration target already exists: ${target}`);
267
+ if (dryRun) return outcome;
268
+
269
+ let suiteSlot;
270
+ let gcExclusion;
271
+ try {
272
+ suiteSlot = acquireSuiteBuildSlot({ layout, pid: process.pid, argv: process.argv, waitMs });
273
+ gcExclusion = acquireMigrationGcExclusion({ layout });
274
+ assertMigrationIdle({ legacyReleaseLockPath, layout });
275
+ const { source } = resolveLegacyMigrationSource({ linkPath, legacyTargetRoot, layout });
276
+ const actualTarget = safeEntryDirectory(root, layout);
277
+ const targetParent = path.dirname(actualTarget);
278
+ mkdirSafeDescendant(root, targetParent);
279
+ const sourceDevice = safeDevice(source);
280
+ if (sourceDevice !== undefined && sourceDevice !== safeDevice(targetParent)) return persistMigrationOutcome({ outcome: { ...outcome, coldCache: true }, receipt, root, dryRun });
281
+
282
+ let movedTarget = false;
283
+ let createdMarker = false;
284
+ let movedCargoHome = false;
285
+ let replacedEmptyCargoHome = false;
286
+ const sharedCargoHome = path.join(root, "cargo-home");
287
+ try {
288
+ renameSync(source, actualTarget); movedTarget = true;
289
+ onPostMove?.("after-target-move");
290
+ createdMarker = !existsSync(path.join(actualTarget, MARKER));
291
+ ensureCacheEntry({ layout });
292
+ onPostMove?.("after-entry-marker");
293
+ if (legacyCargoHome && existsSync(legacyCargoHome)) {
294
+ if (lstatSync(legacyCargoHome).isSymbolicLink()) throw new Error(`legacy Cargo home migration refuses symlink source: ${legacyCargoHome}`);
295
+ if (!existsSync(sharedCargoHome)) {
296
+ renameSync(legacyCargoHome, sharedCargoHome); outcome.cargoHomeMoved = movedCargoHome = true;
297
+ } else if (isEmptyDirectory(sharedCargoHome)) {
298
+ rmSync(sharedCargoHome, { recursive: true, force: false }); replacedEmptyCargoHome = true;
299
+ renameSync(legacyCargoHome, sharedCargoHome); outcome.cargoHomeMoved = movedCargoHome = true;
300
+ } else outcome.deferredLegacyCargoHome = path.resolve(legacyCargoHome);
301
+ }
302
+ onPostMove?.("after-cargo-home-move");
303
+ mkdirSafeDescendant(root, path.join(root, "migration"));
304
+ onPostMove?.("before-receipt");
305
+ writeMigrationReceipt(receipt, { ...outcome, moved: true, dryRun: false, completedAt: iso(new Date()) });
306
+ outcome.moved = true;
307
+ if (linkPath !== source) { try { if (lstatSync(linkPath).isSymbolicLink()) unlinkSync(linkPath); } catch { /* already absent */ } }
308
+ } catch (error) {
309
+ if (movedCargoHome && existsSync(sharedCargoHome) && !existsSync(legacyCargoHome)) renameSync(sharedCargoHome, legacyCargoHome);
310
+ if (replacedEmptyCargoHome && !existsSync(sharedCargoHome)) mkdirSafeDescendant(root, sharedCargoHome);
311
+ if (movedTarget && existsSync(actualTarget) && !existsSync(source)) {
312
+ if (createdMarker) safeUnlink(path.join(actualTarget, MARKER));
313
+ renameSync(actualTarget, source);
314
+ }
315
+ throw error;
316
+ }
317
+ return outcome;
318
+ } finally {
319
+ gcExclusion?.release();
320
+ suiteSlot?.release();
321
+ }
322
+ }
323
+
324
+ function persistMigrationOutcome({ outcome, receipt, root, dryRun }) { if (!dryRun) { mkdirSafeDescendant(root, path.join(root, "migration")); atomicJson(receipt, { ...outcome, dryRun: false, completedAt: iso(new Date()) }); } return outcome; }
325
+
326
+ function resolveLegacyMigrationSource({ linkPath, legacyTargetRoot, layout }) {
327
+ if (!pathEntryExists(linkPath)) return { source: linkPath, coldCache: true };
328
+ if (!lstatSync(linkPath).isSymbolicLink()) return { source: linkPath, coldCache: false };
329
+ if (!legacyTargetRoot) throw new Error(`legacy cache migration refuses unexpected target link: ${linkPath}`);
330
+ const declared = path.resolve(legacyTargetRoot);
331
+ const legacyCargoTargetRoot = path.resolve(declared, "..", "..");
332
+ const exactFingerprintDir = path.join(legacyCargoTargetRoot, layout.platform, layout.fingerprint);
333
+ if (declared !== exactFingerprintDir) throw new Error(`legacy cache migration refuses unexpected target link: ${linkPath}`);
334
+ let physical;
335
+ let expected;
336
+ let legacyRoot;
337
+ try {
338
+ physical = realpathSync(linkPath);
339
+ expected = realpathSync(exactFingerprintDir);
340
+ legacyRoot = realpathSync(legacyCargoTargetRoot);
341
+ } catch { throw new Error(`legacy cache migration refuses unexpected target link: ${linkPath}`); }
342
+ try { assertLexicalInside(legacyRoot, expected); } catch { throw new Error(`legacy cache migration refuses unexpected target link: ${linkPath}`); }
343
+ if (physical !== expected) throw new Error(`legacy cache migration refuses unexpected target link: ${linkPath}`);
344
+ return { source: physical, coldCache: false };
345
+ }
346
+
347
+ function acquireMigrationGcExclusion({ layout }) {
348
+ const payload = { schema: CACHE_SCHEMA, pid: process.pid, argv: process.argv, createdAt: iso(new Date()), order: CACHE_LOCK_ORDER };
349
+ acquireAtomicLock(layout.gcLockPath, payload, "global GC lock", new Date());
350
+ return releaseFor(layout.gcLockPath, payload);
351
+ }
352
+
353
+ function assertMigrationIdle({ legacyReleaseLockPath, layout }) {
354
+ if (legacyReleaseLockPath && pathEntryExists(legacyReleaseLockPath)) throw new Error(`cache migrate refused: legacy release lock is live: ${legacyReleaseLockPath}`);
355
+ for (const entry of safeLeaseEntries(layout.leasesDir)) {
356
+ if (entry.name === path.basename(layout.suiteSlotPath)) continue;
357
+ if (entry.symbolicLink) throw new Error(`cache migrate refused: unsafe shared lease path: ${entry.path}`);
358
+ if (!entry.file) continue;
359
+ const lease = readJson(entry.path);
360
+ if (isLiveLease(lease)) throw new Error(`cache migrate refused: shared entry lease is live: pid ${lease.pid}`);
361
+ }
362
+ }
363
+
364
+ function requireLayout(layout) { if (!layout?.targetDir || !layout?.cacheRoot) throw new Error("cache layout is required"); }
365
+ function readPositive(value, fallback) { if (value === undefined || value === "") return fallback; const number = Number(value); if (!Number.isFinite(number) || number <= 0) throw new Error("cache policy values must be positive byte counts"); return number; }
366
+ function iso(value) { return new Date(value).toISOString(); }
367
+ function pathEntryExists(file) { try { lstatSync(file); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; } }
368
+ function resolvedRoot(cacheRoot) { if (!cacheRoot || !path.isAbsolute(cacheRoot)) throw new Error("cacheRoot must be an absolute path"); if (existsSync(cacheRoot) && lstatSync(cacheRoot).isSymbolicLink()) throw new Error(`unsafe symlink cache root: ${cacheRoot}`); mkdirSync(cacheRoot, { recursive: true }); if (lstatSync(cacheRoot).isSymbolicLink()) throw new Error(`unsafe symlink cache root: ${cacheRoot}`); return realpathSync(cacheRoot); }
369
+ function readonlyRoot(cacheRoot) { if (!cacheRoot || !path.isAbsolute(cacheRoot)) throw new Error("cacheRoot must be an absolute path"); if (!existsSync(cacheRoot)) return null; if (lstatSync(cacheRoot).isSymbolicLink()) throw new Error(`unsafe symlink cache root: ${cacheRoot}`); return realpathSync(cacheRoot); }
370
+ function atomicJson(file, value) { mkdirSync(path.dirname(file), { recursive: true }); const temp = `${file}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`; writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); renameSync(temp, file); }
371
+ function readJson(file) { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } }
372
+ function validEntry(entry, layout) { return Boolean(entry && entry.schema === CACHE_SCHEMA && entry.id === layout.id && entry.kind === layout.kind && entry.platform === layout.platform && entry.architecture === layout.architecture && entry.app === layout.app && entry.fingerprint === layout.fingerprint && typeof entry.createdAt === "string" && typeof entry.lastUsedAt === "string" && Object.prototype.hasOwnProperty.call(entry, "lastSuccessfulBuildAt") && entry.toolchain && typeof entry.toolchain === "object"); }
373
+ function assertLexicalInside(parent, child) { const relative = path.relative(path.resolve(parent), path.resolve(child)); if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error("cache path escapes its root"); }
374
+ function assertPhysicalInside(parent, child) { const parentReal = realpathSync(parent); const childReal = realpathSync(child); assertLexicalInside(parentReal, childReal); }
375
+ function safeDirEntries(dir) { try { return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()).map((entry) => ({ name: entry.name, path: path.join(dir, entry.name) })); } catch { return []; } }
376
+ function safeLeaseEntries(dir) { try { return readdirSync(dir, { withFileTypes: true }).map((entry) => ({ name: entry.name, path: path.join(dir, entry.name), file: entry.isFile(), symbolicLink: entry.isSymbolicLink() })); } catch (error) { if (error?.code === "ENOENT") return []; throw error; } }
377
+ function directoryBytes(dir) { let bytes = 0; const walk = (item) => { const stat = lstatSync(item); if (stat.isSymbolicLink()) return; if (stat.isDirectory()) for (const child of readdirSync(item)) walk(path.join(item, child)); else bytes += stat.size; }; try { walk(dir); } catch { return 0; } return bytes; }
378
+ function freeBytes(fs) { return Number(fs.bavail) * Number(fs.bsize); }
379
+ function safeDevice(dir) { try { return statSync(dir).dev; } catch { return undefined; } }
380
+ function isEmptyDirectory(dir) { try { return readdirSync(dir).length === 0; } catch { return false; } }
381
+ function isLiveLease(lease) { return Boolean(lease && Number.isInteger(Number(lease.pid)) && Number(lease.pid) > 0 && processIsAlive(lease.pid)); }
382
+ function processIsAlive(pid) { try { process.kill(Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; } }
383
+ function safeUnlink(file) { try { unlinkSync(file); } catch { /* already gone */ } }
384
+ function acquireAtomicLock(file, payload, label, now) {
385
+ mkdirSync(path.dirname(file), { recursive: true });
386
+ try { const fd = openSync(file, "wx", 0o600); writeFileSync(fd, `${JSON.stringify(payload)}\n`); closeSync(fd); return; } catch (error) {
387
+ if (error.code !== "EEXIST") throw error;
388
+ const holder = readJson(file);
389
+ if (!isLiveLease(holder, now)) { safeUnlink(file); return acquireAtomicLock(file, payload, label, now); }
390
+ throw new Error(`${label} active: pid ${holder.pid}`);
391
+ }
392
+ }
393
+ 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); } }; }
394
+ 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); }
395
+ function defaultSleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
396
+ function safePruneCandidate(root, candidate) {
397
+ if (!candidate?.dir || path.resolve(candidate.dir) === root) return false;
398
+ if (!existsSync(candidate.dir) || lstatSync(candidate.dir).isSymbolicLink()) return false;
399
+ const dir = realpathSync(candidate.dir); const targets = [path.join(root, "targets"), path.join(root, "test-targets")];
400
+ const targetRoot = targets.find((item) => { try { assertLexicalInside(item, dir); return true; } catch { return false; } });
401
+ if (!targetRoot) return false;
402
+ try {
403
+ assertPhysicalInside(targetRoot, dir);
404
+ const marker = readJson(path.join(dir, MARKER));
405
+ const kind = targetRoot.endsWith(`${path.sep}targets`) ? "release" : "test";
406
+ const layout = marker && resolveCacheLayout({ cacheRoot: root, platform: marker.platform, architecture: marker.architecture, app: marker.app, fingerprint: marker.fingerprint, kind });
407
+ return Boolean(layout && layout.targetDir === dir && marker.id === candidate.id && validEntry(marker, layout) && !isLiveLease(readJson(layout.leasePath), new Date()));
408
+ } catch { return false; }
409
+ }
410
+ function safeEntryDirectory(root, layout) { return path.join(root, layout.kind === "release" ? "targets" : "test-targets", layout.platform, layout.app, layout.fingerprint); }
411
+ function mkdirSafeDescendant(root, dir) {
412
+ assertLexicalInside(root, dir);
413
+ let current = root;
414
+ for (const part of path.relative(root, dir).split(path.sep)) {
415
+ current = path.join(current, part);
416
+ if (existsSync(current)) { if (lstatSync(current).isSymbolicLink()) throw new Error(`unsafe symlink cache ancestor: ${current}`); }
417
+ else mkdirSync(current);
418
+ }
419
+ }
420
+ function isAbsoluteForPlatform(value, platform) { return platform === "win" || platform === "win32" ? path.win32.isAbsolute(value) : path.isAbsolute(value); }
421
+ function resolveForPlatform(value, platform) { return platform === "win" || platform === "win32" ? path.win32.resolve(value) : path.resolve(value); }
422
+ function readRustcVerbose() { const result = spawnSync("rustc", ["-vV"], { encoding: "utf8", windowsHide: true }); if (result.status !== 0) throw new Error(`rustc -vV failed: ${(result.stderr || result.error?.message || `exit ${result.status}`).trim()}`); return result.stdout.trim(); }
423
+ function cargoNativeFeatures(cargoTomlPath) { if (!cargoTomlPath || !existsSync(cargoTomlPath)) return []; return [...readFileSync(cargoTomlPath, "utf8").matchAll(/features\s*=\s*\[([^\]]+)\]/g)].flatMap((match) => match[1].match(/"([^"]+)"/g) ?? []).map((value) => value.slice(1, -1)).filter((value) => /sqlcipher|openssl/i.test(value)); }
@@ -0,0 +1,263 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { spawn } from "node:child_process";
7
+ import { once } from "node:events";
8
+
9
+ import {
10
+ CACHE_SCHEMA,
11
+ DEFAULT_DESIRED_FREE_BYTES,
12
+ DEFAULT_HARD_FREE_BYTES,
13
+ DEFAULT_SCCACHE_MAX_BYTES,
14
+ DEFAULT_TARGET_MAX_BYTES,
15
+ acquireCacheLease,
16
+ acquireSuiteBuildSlot,
17
+ applyCachePrune,
18
+ assertWriteVolumeFloors,
19
+ ensureCacheEntry,
20
+ inspectWriteVolumes,
21
+ markCacheEntrySuccessful,
22
+ migrateLegacyCache,
23
+ planCachePrune,
24
+ readCachePolicy,
25
+ resolveCacheLayout,
26
+ resolveSharedCacheIdentity,
27
+ resolveSharedCacheRoot,
28
+ } from "./cache-policy.mjs";
29
+
30
+ function root() { return path.join(os.tmpdir(), `rightkit-cache-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); }
31
+ function layout(dir, fingerprint = "a1b2c3d4e5f60708") {
32
+ return resolveCacheLayout({ cacheRoot: dir, platform: "mac", architecture: "aarch64", app: "fixture", fingerprint, kind: "release" });
33
+ }
34
+
35
+ function migrationFixture() {
36
+ const base = root();
37
+ const fingerprint = "abcdef0123456789";
38
+ const legacyRoot = path.join(base, "repo", "app", "src-tauri", "target");
39
+ const legacyTargetRoot = path.join(base, "repo", ".right-release", "cache", "cargo-target", "mac", fingerprint);
40
+ const legacyCargoHome = path.join(base, "repo", ".right-release", "cache", "cargo-home");
41
+ mkdirSync(legacyTargetRoot, { recursive: true });
42
+ writeFileSync(path.join(legacyTargetRoot, "artifact"), "legacy-target");
43
+ mkdirSync(legacyCargoHome, { recursive: true });
44
+ writeFileSync(path.join(legacyCargoHome, "registry"), "legacy-cargo-home");
45
+ mkdirSync(path.dirname(legacyRoot), { recursive: true });
46
+ symlinkSync(legacyTargetRoot, legacyRoot);
47
+ const sharedCacheRoot = path.join(base, "shared-cache");
48
+ return {
49
+ fingerprint,
50
+ legacyRoot,
51
+ legacyTargetRoot,
52
+ legacyCargoHome,
53
+ layout: layout(sharedCacheRoot, fingerprint),
54
+ app: "fixture",
55
+ legacyReleaseLockPath: path.join(base, "repo", ".right-release", "locks", "mac.lock.json"),
56
+ };
57
+ }
58
+
59
+ test("cache roots are platform-native and overrides must be absolute", () => {
60
+ assert.equal(resolveSharedCacheRoot({ platform: "mac", home: "/Users/test", env: {} }), "/Users/test/Library/Caches/RightSuite/release");
61
+ assert.equal(resolveSharedCacheRoot({ platform: "win", env: { LOCALAPPDATA: "C:/Users/test/AppData/Local" } }), "C:\\Users\\test\\AppData\\Local\\RightSuite\\Cache\\release");
62
+ assert.equal(resolveSharedCacheRoot({ platform: "linux", home: "/home/test", xdgCacheHome: "/tmp/xdg", env: {} }), "/tmp/xdg/rightsuite/release");
63
+ assert.equal(resolveSharedCacheRoot({ platform: "mac", env: { RIGHT_RELEASE_CACHE_ROOT: "/tmp/cache" } }), "/tmp/cache");
64
+ assert.throws(() => resolveSharedCacheRoot({ platform: "mac", env: { RIGHT_RELEASE_CACHE_ROOT: "relative" } }), /absolute/i);
65
+ });
66
+
67
+ test("layout isolates app, platform, kind, and fingerprint", () => {
68
+ const base = root();
69
+ const release = layout(base);
70
+ const testLayout = resolveCacheLayout({ cacheRoot: base, platform: "mac", architecture: "aarch64", app: "other", fingerprint: "a1b2c3d4e5f60708", kind: "test" });
71
+ assert.match(release.targetDir, /targets[\\/]mac[\\/]fixture[\\/]a1b2c3d4e5f60708$/);
72
+ assert.match(testLayout.targetDir, /test-targets[\\/]mac[\\/]other[\\/]a1b2c3d4e5f60708$/);
73
+ assert.notEqual(release.targetDir, testLayout.targetDir);
74
+ });
75
+
76
+ test("policy defaults are the approved target, compiler, desired and hard limits", () => {
77
+ const policy = readCachePolicy({});
78
+ assert.equal(CACHE_SCHEMA, 1);
79
+ assert.equal(policy.targetMaxBytes, DEFAULT_TARGET_MAX_BYTES);
80
+ assert.equal(policy.sccacheMaxBytes, DEFAULT_SCCACHE_MAX_BYTES);
81
+ assert.equal(policy.desiredFreeBytes, DEFAULT_DESIRED_FREE_BYTES);
82
+ assert.equal(policy.hardFreeBytes, DEFAULT_HARD_FREE_BYTES);
83
+ });
84
+
85
+ test("entry metadata is atomic, complete, and updates success only after requested", () => {
86
+ const entry = layout(root());
87
+ ensureCacheEntry({ layout: entry, metadata: { toolchain: { cargo: "cargo 1", rustc: "rustc 1", host: "host" } }, now: new Date("2026-07-21T00:00:00Z") });
88
+ const created = JSON.parse(readFileSync(entry.markerPath, "utf8"));
89
+ assert.equal(created.schema, CACHE_SCHEMA);
90
+ assert.equal(created.id, "target:mac:fixture:a1b2c3d4e5f60708");
91
+ assert.equal(created.lastSuccessfulBuildAt, null);
92
+ markCacheEntrySuccessful({ layout: entry, now: new Date("2026-07-21T01:00:00Z") });
93
+ assert.equal(JSON.parse(readFileSync(entry.markerPath, "utf8")).lastSuccessfulBuildAt, "2026-07-21T01:00:00.000Z");
94
+ });
95
+
96
+ test("entry leases protect live entries, reclaim stale owners, and suite slots time out without a fallback", () => {
97
+ const entry = layout(root());
98
+ ensureCacheEntry({ layout: entry });
99
+ const lease = acquireCacheLease({ layout: entry, pid: process.pid, argv: ["test"] });
100
+ assert.throws(() => acquireCacheLease({ layout: entry, pid: process.pid + 100000, argv: ["second"] }), /lease.*active/i);
101
+ lease.release();
102
+ writeFileSync(entry.leasePath, JSON.stringify({ pid: 99999999, createdAt: "2000-01-01T00:00:00.000Z" }));
103
+ const reclaimed = acquireCacheLease({ layout: entry, pid: process.pid, argv: ["reclaimed"] });
104
+ reclaimed.release();
105
+ const slot = acquireSuiteBuildSlot({ layout: entry, pid: process.pid, argv: ["holder"], waitMs: 0 });
106
+ assert.throws(() => acquireSuiteBuildSlot({ layout: entry, pid: process.pid + 100000, argv: ["waiter"], waitMs: 0, sleep: () => {} }), /suite build slot.*active|timed out/i);
107
+ slot.release();
108
+ });
109
+
110
+ test("build and migration derive identical native-feature cache fingerprints", () => {
111
+ const base = root(); const cargoLock = path.join(base, "Cargo.lock"); const cargoToml = path.join(base, "Cargo.toml");
112
+ mkdirSync(base, { recursive: true });
113
+ writeFileSync(cargoLock, "[[package]]\nname = 'fixture'\n");
114
+ writeFileSync(cargoToml, "[dependencies]\nrusqlite = { version = '1', features = [\"bundled-sqlcipher\"] }\n");
115
+ const inputs = { cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: "rustc 1.91.0\nhost: aarch64-apple-darwin", targetTriple: "aarch64-apple-darwin", profile: "release" };
116
+ const build = resolveSharedCacheIdentity(inputs);
117
+ const migration = resolveSharedCacheIdentity(inputs);
118
+ assert.equal(build.fingerprint, migration.fingerprint);
119
+ assert.notEqual(build.fingerprint, resolveSharedCacheIdentity({ ...inputs, features: [] }).fingerprint);
120
+ });
121
+
122
+ test("migration moves an actual src-tauri target symlink and keeps dry-run physically pure", () => {
123
+ const fixture = migrationFixture();
124
+ const preview = migrateLegacyCache({ ...fixture, dryRun: true });
125
+ assert.equal(preview.moved, false);
126
+ assert.equal(existsSync(fixture.layout.cacheRoot), false);
127
+ assert.equal(lstatSync(fixture.legacyRoot).isSymbolicLink(), true);
128
+ const result = migrateLegacyCache({ ...fixture, dryRun: false });
129
+ assert.equal(result.moved, true);
130
+ assert.equal(existsSync(fixture.legacyRoot), false);
131
+ assert.equal(readFileSync(path.join(fixture.layout.targetDir, "artifact"), "utf8"), "legacy-target");
132
+ assert.equal(readFileSync(path.join(fixture.layout.cargoHome, "registry"), "utf8"), "legacy-cargo-home");
133
+ });
134
+
135
+ test("migration rejects target symlinks outside the exact derived fingerprint directory", () => {
136
+ const fixture = migrationFixture();
137
+ const other = path.join(path.dirname(fixture.legacyTargetRoot), "other-fingerprint");
138
+ mkdirSync(other, { recursive: true });
139
+ symlinkSync(other, `${fixture.legacyRoot}-wrong`);
140
+ assert.throws(() => migrateLegacyCache({ ...fixture, legacyRoot: `${fixture.legacyRoot}-wrong`, dryRun: true }), /unexpected target link/i);
141
+ symlinkSync(path.join(other, "missing"), `${fixture.legacyRoot}-dangling`);
142
+ assert.throws(() => migrateLegacyCache({ ...fixture, legacyRoot: `${fixture.legacyRoot}-dangling`, dryRun: true }), /unexpected target link/i);
143
+ });
144
+
145
+ test("migration rolls back target, Cargo home, and src-tauri link after every injected post-move failure", () => {
146
+ for (const failureAt of ["after-target-move", "after-entry-marker", "after-cargo-home-move", "before-receipt", "receipt-write"]) {
147
+ const fixture = migrationFixture();
148
+ assert.throws(() => migrateLegacyCache({ ...fixture, dryRun: false, onPostMove: (step) => { if (step === failureAt) throw new Error(`injected ${step}`); }, writeMigrationReceipt: failureAt === "receipt-write" ? () => { throw new Error("injected receipt write"); } : undefined }), /injected/);
149
+ assert.equal(readFileSync(path.join(fixture.legacyTargetRoot, "artifact"), "utf8"), "legacy-target", failureAt);
150
+ assert.equal(realpathSync(fixture.legacyRoot), realpathSync(fixture.legacyTargetRoot), failureAt);
151
+ assert.equal(lstatSync(fixture.legacyRoot).isSymbolicLink(), true, failureAt);
152
+ assert.equal(readFileSync(path.join(fixture.legacyCargoHome, "registry"), "utf8"), "legacy-cargo-home", failureAt);
153
+ assert.equal(existsSync(fixture.layout.targetDir), false, failureAt);
154
+ assert.equal(existsSync(fixture.layout.cargoHome), false, failureAt);
155
+ }
156
+ });
157
+
158
+ test("a competing suite build slot blocks migration before it moves a legacy cache", () => {
159
+ const fixture = migrationFixture();
160
+ const slot = acquireSuiteBuildSlot({ layout: fixture.layout, pid: process.pid, argv: ["build"], waitMs: 0 });
161
+ try {
162
+ assert.throws(() => migrateLegacyCache({ ...fixture, dryRun: false, waitMs: 0 }), /suite build slot.*active|timed out/i);
163
+ assert.equal(realpathSync(fixture.legacyRoot), realpathSync(fixture.legacyTargetRoot));
164
+ assert.equal(existsSync(fixture.layout.targetDir), false);
165
+ } finally { slot.release(); }
166
+ });
167
+
168
+ test("a live shared build lease blocks migration during in-lock revalidation", () => {
169
+ const fixture = migrationFixture();
170
+ mkdirSync(fixture.layout.leasesDir, { recursive: true });
171
+ writeFileSync(path.join(fixture.layout.leasesDir, "another-build.json"), JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
172
+ assert.throws(() => migrateLegacyCache({ ...fixture, dryRun: false }), /shared entry lease is live/i);
173
+ assert.equal(realpathSync(fixture.legacyRoot), realpathSync(fixture.legacyTargetRoot));
174
+ assert.equal(existsSync(fixture.layout.targetDir), false);
175
+ assert.equal(existsSync(fixture.layout.gcLockPath), false);
176
+ assert.equal(existsSync(fixture.layout.suiteSlotPath), false);
177
+ });
178
+
179
+ test("a live legacy release lock blocks migration during in-lock revalidation", () => {
180
+ const fixture = migrationFixture();
181
+ mkdirSync(path.dirname(fixture.legacyReleaseLockPath), { recursive: true });
182
+ writeFileSync(fixture.legacyReleaseLockPath, JSON.stringify({ pid: process.pid }));
183
+ assert.throws(() => migrateLegacyCache({ ...fixture, dryRun: false }), /legacy release lock is live/i);
184
+ assert.equal(realpathSync(fixture.legacyRoot), realpathSync(fixture.legacyTargetRoot));
185
+ assert.equal(existsSync(fixture.layout.targetDir), false);
186
+ assert.equal(existsSync(fixture.layout.gcLockPath), false);
187
+ assert.equal(existsSync(fixture.layout.suiteSlotPath), false);
188
+ });
189
+
190
+ test("pruning is oldest successful use first and dry-run equals apply candidates", () => {
191
+ const base = root();
192
+ const older = layout(base, "1111111111111111");
193
+ const newer = layout(base, "2222222222222222");
194
+ for (const [entry, stamp] of [[older, "2026-01-01T00:00:00.000Z"], [newer, "2026-02-01T00:00:00.000Z"]]) {
195
+ ensureCacheEntry({ layout: entry, now: new Date(stamp) });
196
+ writeFileSync(path.join(entry.targetDir, "payload"), "x".repeat(32));
197
+ markCacheEntrySuccessful({ layout: entry, now: new Date(stamp) });
198
+ }
199
+ const snapshot = { cacheRoot: base, targetBytes: 64, entries: [
200
+ { id: newer.id, dir: newer.targetDir, bytes: 32, marker: JSON.parse(readFileSync(newer.markerPath)), leased: false },
201
+ { id: older.id, dir: older.targetDir, bytes: 32, marker: JSON.parse(readFileSync(older.markerPath)), leased: false },
202
+ ] };
203
+ const plan = planCachePrune({ snapshot, policy: { targetMaxBytes: 31, desiredFreeBytes: 0 }, protectedEntryIds: new Set() });
204
+ assert.deepEqual(plan.candidates.map((x) => x.id), [older.id, newer.id]);
205
+ assert.deepEqual(applyCachePrune({ plan, cacheRoot: base, dryRun: true }).candidateIds, applyCachePrune({ plan, cacheRoot: base, dryRun: false }).candidateIds);
206
+ assert.equal(existsSync(older.targetDir), false);
207
+ assert.equal(existsSync(newer.targetDir), false);
208
+ });
209
+
210
+ test("pruning fails closed for malformed markers, symlink escapes, and cache root", () => {
211
+ const base = root();
212
+ const safe = layout(base);
213
+ mkdirSync(safe.targetDir, { recursive: true });
214
+ writeFileSync(safe.markerPath, "not-json");
215
+ const outside = root(); mkdirSync(outside, { recursive: true });
216
+ const link = layout(base, "2222222222222222"); mkdirSync(path.dirname(link.targetDir), { recursive: true }); symlinkSync(outside, link.targetDir);
217
+ const plan = { candidates: [
218
+ { id: "bad", dir: safe.targetDir, bytes: 1 },
219
+ { id: "link", dir: link.targetDir, bytes: 1 },
220
+ { id: "root", dir: base, bytes: 1 },
221
+ ] };
222
+ const result = applyCachePrune({ plan, cacheRoot: base, dryRun: false });
223
+ assert.deepEqual(result.removedIds, []);
224
+ assert.equal(existsSync(safe.targetDir), true);
225
+ assert.equal(lstatSync(link.targetDir).isSymbolicLink(), true);
226
+ });
227
+
228
+ test("entry creation rejects a symlinked cache ancestor before writing outside the cache root", () => {
229
+ const parent = root(); const outside = root(); mkdirSync(parent, { recursive: true }); mkdirSync(outside, { recursive: true });
230
+ symlinkSync(outside, path.join(parent, "cache"));
231
+ const unsafe = resolveCacheLayout({ cacheRoot: path.join(parent, "cache"), platform: "mac", architecture: "aarch64", app: "fixture", fingerprint: "3333333333333333", kind: "release" });
232
+ assert.throws(() => ensureCacheEntry({ layout: unsafe }), /symlink|unsafe/i);
233
+ assert.equal(existsSync(path.join(outside, "targets")), false);
234
+ });
235
+
236
+ test("prune takes the global GC lock and blocks a racing lease before deletion", () => {
237
+ const base = root(); const entry = layout(base, "4444444444444444"); ensureCacheEntry({ layout: entry }); writeFileSync(path.join(entry.targetDir, "payload"), "x");
238
+ const plan = { cacheRoot: base, candidates: [{ id: entry.id, dir: entry.targetDir, bytes: 1 }] };
239
+ assert.throws(() => applyCachePrune({ plan, cacheRoot: base, dryRun: false, beforeDelete: () => acquireCacheLease({ layout: entry }) }), /global GC lock active/);
240
+ assert.equal(existsSync(entry.targetDir), true);
241
+ assert.equal(existsSync(path.join(base, "gc", "gc.lock.json")), false);
242
+ });
243
+
244
+ test("a real child-process lease acquired after planning excludes its entry from prune", async () => {
245
+ const base = root(); const entry = layout(base, "5555555555555555"); ensureCacheEntry({ layout: entry }); writeFileSync(path.join(entry.targetDir, "payload"), "x");
246
+ const plan = { cacheRoot: base, candidates: [{ id: entry.id, dir: entry.targetDir, bytes: 1 }] };
247
+ const moduleUrl = new URL("./cache-policy.mjs", import.meta.url).href;
248
+ const child = spawn(process.execPath, ["--input-type=module", "--eval", `import { resolveCacheLayout, acquireCacheLease } from ${JSON.stringify(moduleUrl)}; const l=resolveCacheLayout({cacheRoot:process.argv[1],platform:'mac',architecture:'aarch64',app:'fixture',fingerprint:'5555555555555555',kind:'release'}); const lease=acquireCacheLease({layout:l}); console.log('ready'); setTimeout(()=>lease.release(),500);`, base], { stdio: ["ignore", "pipe", "pipe"] });
249
+ await once(child.stdout, "data");
250
+ const result = applyCachePrune({ plan, cacheRoot: base, dryRun: false });
251
+ assert.deepEqual(result.removedIds, []);
252
+ assert.equal(existsSync(entry.targetDir), true);
253
+ await once(child, "exit");
254
+ });
255
+
256
+ test("write-volume inspection deduplicates same devices and enforces both hard floors", () => {
257
+ const statfs = (dir) => ({ bsize: 1024, bavail: dir.includes("cache") ? 20 : 30, dev: dir.includes("same") ? 1 : dir.includes("cache") ? 2 : 3 });
258
+ const same = inspectWriteVolumes({ repoRoot: "/same/repo", vaultRoot: "/same/vault", cacheRoot: "/same/cache", statfs });
259
+ assert.equal(same.length, 1);
260
+ const distinct = inspectWriteVolumes({ repoRoot: "/repo", vaultRoot: "/repo", cacheRoot: "/cache", statfs });
261
+ assert.equal(distinct.length, 2);
262
+ assert.throws(() => assertWriteVolumeFloors({ volumes: distinct, policy: { hardFreeBytes: 25 * 1024 }, configuredRepoMinimumBytes: 25 * 1024 }), /cache|repo/i);
263
+ });
@@ -8,6 +8,13 @@ const CRATES_IO_SOURCES = new Set([
8
8
  "registry+https://github.com/rust-lang/crates.io-index",
9
9
  "registry+https://index.crates.io/",
10
10
  ]);
11
+ const CACHE_V2_ENVIRONMENT = ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER", "SCCACHE_DIR", "SCCACHE_BASEDIRS", "RIGHT_RELEASE_CACHE_OWNER"];
12
+
13
+ export function isolatedCargoMetadataEnv(cargoHome, env = process.env) {
14
+ const metadataEnv = { ...env };
15
+ for (const name of CACHE_V2_ENVIRONMENT) delete metadataEnv[name];
16
+ return { ...metadataEnv, CARGO_HOME: cargoHome };
17
+ }
11
18
 
12
19
  export function validateRightKitCargoContract(root, publishedVersions, label = path.basename(root)) {
13
20
  const scanRoot = path.resolve(root);
@@ -70,7 +77,7 @@ function readCargoManifestDependencies(manifestPath, cargoHome, label) {
70
77
  {
71
78
  cwd: path.dirname(manifestPath),
72
79
  encoding: "utf8",
73
- env: { ...process.env, CARGO_HOME: cargoHome },
80
+ env: isolatedCargoMetadataEnv(cargoHome),
74
81
  windowsHide: true,
75
82
  },
76
83
  );
@@ -42,6 +42,8 @@ if (first === "--version" || first === "-v") {
42
42
  ["--test", path.join(packageRoot, "right-suite-contract.test.mjs")],
43
43
  "[right-release] suite-doctor passed",
44
44
  );
45
+ } else if (first === "cache") {
46
+ run("cache-command.mjs", args.slice(1));
45
47
  } else if (first === "publish") {
46
48
  const rest = args.slice(1);
47
49
  if (rest[0] === "cargo") {
@@ -133,6 +135,10 @@ Commands:
133
135
  doctor [--platform mac|win] Inspect one app's release config
134
136
  doctor --all Verify all Right Suite app release contracts
135
137
  suite-doctor Verify all local Right Suite repositories
138
+ cache status [--json] Inspect the shared local build cache
139
+ cache prune [--dry-run|--apply] [--json] Plan or apply safe shared-target pruning
140
+ cache migrate --config <file> [--dry-run|--apply] [--json]
141
+ Move a same-volume legacy target without copying
136
142
  deps --check|--audit|--update Shared dependency lane
137
143
  hardening <artifact...> Run the Right Suite hardening scan
138
144
  lsclean <AppName.app> Clear macOS LaunchServices duplicates
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.38",
3
+ "version": "0.2.39",
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": {
package/release-state.mjs CHANGED
@@ -2,12 +2,14 @@ import { createHash } from "node:crypto";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { existsSync, readFileSync, watch } from "node:fs";
4
4
  import path from "node:path";
5
+ import { DEFAULT_SCCACHE_MAX_BYTES, resolveCacheLayout } from "./cache-policy.mjs";
5
6
 
6
- export function cacheFingerprint({ cargoLockSha256, rustc, target, features = [] }) {
7
+ export function cacheFingerprint({ cargoLockSha256, rustc, target, architecture, features = [] }) {
7
8
  const payload = JSON.stringify({
8
9
  cargoLockSha256,
9
10
  rustc,
10
11
  target,
12
+ architecture,
11
13
  features: [...features].sort(),
12
14
  });
13
15
  return createHash("sha256").update(payload).digest("hex").slice(0, 16);
@@ -59,14 +61,29 @@ export function watchProgress(paths, onProgress) {
59
61
  };
60
62
  }
61
63
 
62
- export function releaseEnvironment({ root, platform, cacheKey, kind = "release" }) {
64
+ export function releaseEnvironment({ root, cacheRoot, platform, architecture, app, cacheKey, kind = "release", appRoot, mode = "legacy" }) {
63
65
  if (kind !== "release" && kind !== "test") throw new Error(`invalid target kind: ${kind}`);
66
+ if (mode !== "legacy" && mode !== "shared") throw new Error(`invalid cache mode: ${mode}`);
67
+ if (mode === "shared") {
68
+ if (!cacheRoot || !platform || !architecture || !app || !cacheKey || !appRoot) throw new Error("shared release environment requires cacheRoot, platform, architecture, app, cacheKey, and appRoot");
69
+ const layout = resolveCacheLayout({ cacheRoot, platform, architecture, app, fingerprint: cacheKey, kind });
70
+ return {
71
+ CARGO_TARGET_DIR: layout.targetDir,
72
+ CARGO_HOME: layout.cargoHome,
73
+ SCCACHE_DIR: layout.sccacheDir,
74
+ SCCACHE_CACHE_SIZE: `${DEFAULT_SCCACHE_MAX_BYTES / 1024 ** 3}G`,
75
+ SCCACHE_BASEDIRS: [path.resolve(root), path.resolve(appRoot)].join(path.delimiter),
76
+ RUSTC_WRAPPER: "sccache",
77
+ RIGHT_RELEASE_CACHE_OWNER: "rightkit-v2",
78
+ };
79
+ }
64
80
  const targetKind = kind === "release" ? "cargo-target" : "test-target";
65
81
  return {
66
82
  CARGO_TARGET_DIR: path.resolve(root, "cache", targetKind, platform, cacheKey),
67
83
  CARGO_HOME: path.resolve(root, "cache", "cargo-home"),
68
84
  SCCACHE_DIR: path.resolve(root, "cache", "sccache"),
69
85
  RUSTC_WRAPPER: "sccache",
86
+ RIGHT_RELEASE_CACHE_OWNER: undefined,
70
87
  };
71
88
  }
72
89
 
@@ -49,6 +49,22 @@ test("nested app configs keep the vault at repo root and build from the app root
49
49
  assert.equal(layout.targetLink, path.resolve("D:/suite/heardright/tauri-app-next/src-tauri/target"));
50
50
  });
51
51
 
52
+ test("shared release environment is isolated from the app vault", () => {
53
+ const env = releaseEnvironment({
54
+ root: "/suite/.right-release",
55
+ cacheRoot: "/Users/test/Library/Caches/RightSuite/release",
56
+ platform: "mac",
57
+ architecture: "aarch64",
58
+ app: "fixture",
59
+ appRoot: "/suite/fixture",
60
+ cacheKey: "abcdef0123456789",
61
+ mode: "shared",
62
+ });
63
+ assert.match(env.CARGO_TARGET_DIR, /RightSuite\/release\/targets\/mac\/fixture\/abcdef0123456789$/);
64
+ assert.match(env.CARGO_HOME, /RightSuite\/release\/cargo-home$/);
65
+ assert.equal(env.RIGHT_RELEASE_CACHE_OWNER, "rightkit-v2");
66
+ });
67
+
52
68
  function sha256(value) {
53
69
  return createHash("sha256").update(value).digest("hex");
54
70
  }
package/release.mjs CHANGED
@@ -61,6 +61,7 @@ for (let i = 0; i < args.length; i++) {
61
61
 
62
62
  if (opts.upload) fail("combined build+upload was removed; run right-release build, then right-release upload --release <id> --tier patch|update");
63
63
  if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
64
+ const sccacheVersion = assertSharedSccachePrerequisite();
64
65
 
65
66
  const configPath = path.resolve(opts.config);
66
67
  const config = (await import(pathToFileURL(configPath))).default;
@@ -92,6 +93,7 @@ if (opts.doctor) {
92
93
  console.log(`packageManager: ${config.packageManager}`);
93
94
  console.log(`workdir: ${workdir}`);
94
95
  console.log(`hardeningscan: ${HARDENING_SCAN}`);
96
+ if (sccacheVersion) console.log(`sccache: ${sccacheVersion}`);
95
97
  if (legalContract) {
96
98
  console.log(`legal: ${legalContract.manifestPath}`);
97
99
  console.log(`legalAcceptance: ${legalContract.acceptanceVersion}`);
@@ -156,7 +158,11 @@ if (opts.upload && !target.publish) {
156
158
  }
157
159
  }
158
160
 
159
- sweepStaleRustArtifacts();
161
+ if (process.env.RIGHT_RELEASE_CACHE_MODE === "shared" && process.env.RIGHT_RELEASE_CACHE_OWNER === "rightkit-v2") {
162
+ console.log("right-release: shared cache prune delegated to RightKit Cache V2");
163
+ } else {
164
+ sweepStaleRustArtifacts();
165
+ }
160
166
 
161
167
  console.log(`right-release: done (${Date.now() - started}ms)`);
162
168
  releaseLock.release();
@@ -172,6 +178,26 @@ function fail(message) {
172
178
  process.exit(1);
173
179
  }
174
180
 
181
+ function assertSharedSccachePrerequisite() {
182
+ if (process.env.RIGHT_RELEASE_CACHE_MODE !== "shared") return null;
183
+ const result = spawnSync("sccache", ["--version"], { encoding: "utf8", windowsHide: true });
184
+ const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
185
+ const version = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
186
+ const valid = result.status === 0 && version && isAtLeastVersion(version.slice(1).map(Number), [0, 15, 0]);
187
+ if (!valid) {
188
+ const found = version ? ` (found ${version[0]})` : "";
189
+ fail(`shared cache mode requires sccache >= 0.15.0${found}. Install with: cargo install sccache --locked; then verify: sccache --version`);
190
+ }
191
+ return version[0];
192
+ }
193
+
194
+ function isAtLeastVersion(actual, minimum) {
195
+ for (let index = 0; index < minimum.length; index += 1) {
196
+ if (actual[index] !== minimum[index]) return actual[index] > minimum[index];
197
+ }
198
+ return true;
199
+ }
200
+
175
201
  async function mustExist(file, message) {
176
202
  if (opts.dryRun) return;
177
203
  await access(file).catch(() => fail(message));
@@ -317,7 +317,7 @@ test("Cargo version contract rejects staged versions that mismatch canonical man
317
317
  test("RightKit exposes one current version manifest", () => {
318
318
  assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
319
319
  assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
320
- assert.equal(versions.npm["@rightkit/release"], "0.2.38");
320
+ assert.equal(versions.npm["@rightkit/release"], "0.2.39");
321
321
  assert.equal(versions.npm["@rightkit/legal"], "0.2.0");
322
322
  assert.equal(versions.npm["@rightkit/license"], "0.1.5");
323
323
  assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
@@ -7,7 +7,7 @@
7
7
  "@rightkit/logs": "0.1.3",
8
8
  "@rightkit/platform-ui": "0.1.0",
9
9
  "@rightkit/qa": "0.1.0",
10
- "@rightkit/release": "0.2.38",
10
+ "@rightkit/release": "0.2.39",
11
11
  "@rightkit/tauri": "0.1.0",
12
12
  "@rightkit/updates": "0.2.3"
13
13
  },