@rightkit/release 0.2.38 → 0.2.40
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-invocation-contract.test.mjs +33 -5
- package/build-release.mjs +79 -47
- package/build-release.test.mjs +44 -0
- package/cache-command.mjs +73 -0
- package/cache-command.test.mjs +73 -0
- package/cache-policy.mjs +423 -0
- package/cache-policy.test.mjs +263 -0
- package/cargo-contract.mjs +8 -1
- package/cli/right-release.mjs +6 -0
- package/package.json +1 -1
- package/release-invocation.mjs +21 -7
- package/release-state.mjs +26 -2
- package/release-state.test.mjs +50 -0
- package/release.mjs +27 -1
- package/right-suite-contract.test.mjs +78 -11
- package/rightkit-versions.json +1 -1
|
@@ -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
|
+
});
|
package/cargo-contract.mjs
CHANGED
|
@@ -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:
|
|
80
|
+
env: isolatedCargoMetadataEnv(cargoHome),
|
|
74
81
|
windowsHide: true,
|
|
75
82
|
},
|
|
76
83
|
);
|
package/cli/right-release.mjs
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.2.40",
|
|
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-invocation.mjs
CHANGED
|
@@ -21,6 +21,8 @@ export function assertPrimaryReleaseCheckout(repoRoot) {
|
|
|
21
21
|
if (canonical(repoRoot) !== canonical(primary)) {
|
|
22
22
|
throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
|
|
23
23
|
}
|
|
24
|
+
const branch = spawnSync("git", ["symbolic-ref", "--quiet", "HEAD"], { cwd: repoRoot, encoding: "utf8", windowsHide: true });
|
|
25
|
+
if (branch.status !== 0) throw new Error("right-release requires a branch-attached primary Git checkout; detached HEAD is forbidden");
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
function pathspec(pattern, exclude = false) {
|
|
@@ -55,9 +57,25 @@ export function dirtyBuildInputs(repoRoot, inputs, baseline = "HEAD") {
|
|
|
55
57
|
if (!Array.isArray(inputs?.include) || inputs.include.length === 0) {
|
|
56
58
|
throw new Error("release config must declare non-empty buildInputs.include paths");
|
|
57
59
|
}
|
|
60
|
+
const required = new Set(inputs.required ?? []);
|
|
61
|
+
const files = [
|
|
62
|
+
...statusFiles(repoRoot, inputs.include, inputs.exclude),
|
|
63
|
+
...statusFiles(repoRoot, [...required], []),
|
|
64
|
+
];
|
|
65
|
+
return [...new Set(files)]
|
|
66
|
+
.filter((file) => required.has(file)
|
|
67
|
+
? fileChangedFromBaseline(repoRoot, file, baseline)
|
|
68
|
+
: inputs.json?.[file]
|
|
69
|
+
? jsonProjectionChanged(repoRoot, file, inputs.json[file], baseline)
|
|
70
|
+
: fileChangedFromBaseline(repoRoot, file, baseline))
|
|
71
|
+
.sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function statusFiles(repoRoot, include, exclude = []) {
|
|
75
|
+
if (!include.length) return [];
|
|
58
76
|
const specs = [
|
|
59
|
-
...
|
|
60
|
-
...
|
|
77
|
+
...include.map((pattern) => pathspec(pattern)),
|
|
78
|
+
...exclude.map((pattern) => pathspec(pattern, true)),
|
|
61
79
|
];
|
|
62
80
|
const output = git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...specs]);
|
|
63
81
|
const records = output.split("\0");
|
|
@@ -69,9 +87,5 @@ export function dirtyBuildInputs(repoRoot, inputs, baseline = "HEAD") {
|
|
|
69
87
|
files.push(record.slice(3).replaceAll("\\", "/"));
|
|
70
88
|
if (/[RC]/.test(status)) index += 1;
|
|
71
89
|
}
|
|
72
|
-
return
|
|
73
|
-
.filter((file) => inputs.json?.[file]
|
|
74
|
-
? jsonProjectionChanged(repoRoot, file, inputs.json[file], baseline)
|
|
75
|
-
: fileChangedFromBaseline(repoRoot, file, baseline))
|
|
76
|
-
.sort();
|
|
90
|
+
return files;
|
|
77
91
|
}
|
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
|
|
|
@@ -94,6 +111,8 @@ export async function runBuildStateMachine({
|
|
|
94
111
|
commit,
|
|
95
112
|
platform,
|
|
96
113
|
requiredInputs = [],
|
|
114
|
+
inputHashes,
|
|
115
|
+
cacheKey,
|
|
97
116
|
ops,
|
|
98
117
|
}) {
|
|
99
118
|
const releaseId = `${app}-${version}-${commit.slice(0, 8)}`;
|
|
@@ -104,6 +123,11 @@ export async function runBuildStateMachine({
|
|
|
104
123
|
if (sealed.manifest.app !== app || sealed.manifest.version !== version || sealed.manifest.commit !== commit) {
|
|
105
124
|
throw new Error(`sealed release identity mismatch: ${releaseId}`);
|
|
106
125
|
}
|
|
126
|
+
if (sealed.manifest.platform !== platform) throw new Error(`sealed release platform identity mismatch: ${releaseId}`);
|
|
127
|
+
if (inputHashes && JSON.stringify(sealed.manifest.inputs) !== JSON.stringify(inputHashes)) {
|
|
128
|
+
throw new Error(`sealed release input identity mismatch: ${releaseId}`);
|
|
129
|
+
}
|
|
130
|
+
if (cacheKey && sealed.manifest.cacheKey !== cacheKey) throw new Error(`sealed release cache identity mismatch: ${releaseId}`);
|
|
107
131
|
return { status: "sealed", resumed: true, releaseId, sealedDir };
|
|
108
132
|
}
|
|
109
133
|
await ops.preflight?.({ root, app, version, commit, platform, requiredInputs, releaseId });
|
package/release-state.test.mjs
CHANGED
|
@@ -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
|
}
|
|
@@ -182,6 +198,40 @@ test("a valid sealed release resumes without rebuilding", async () => {
|
|
|
182
198
|
assert.equal(builds, 0);
|
|
183
199
|
});
|
|
184
200
|
|
|
201
|
+
test("sealed build resume rejects changed input or cache identity", async () => {
|
|
202
|
+
const fx = fixture();
|
|
203
|
+
fx.manifest.inputs = { "src-tauri/Cargo.lock": "old-hash" };
|
|
204
|
+
fx.manifest.cacheKey = "old-cache";
|
|
205
|
+
writeFileSync(path.join(fx.sealedDir, "release-manifest.json"), `${JSON.stringify(fx.manifest, null, 2)}\n`);
|
|
206
|
+
|
|
207
|
+
await assert.rejects(
|
|
208
|
+
runBuildStateMachine({
|
|
209
|
+
root: fx.root,
|
|
210
|
+
app: fx.manifest.app,
|
|
211
|
+
version: fx.manifest.version,
|
|
212
|
+
commit: fx.manifest.commit,
|
|
213
|
+
platform: fx.manifest.platform,
|
|
214
|
+
inputHashes: { "src-tauri/Cargo.lock": "new-hash" },
|
|
215
|
+
cacheKey: fx.manifest.cacheKey,
|
|
216
|
+
ops: {},
|
|
217
|
+
}),
|
|
218
|
+
/sealed release input identity mismatch/i,
|
|
219
|
+
);
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
runBuildStateMachine({
|
|
222
|
+
root: fx.root,
|
|
223
|
+
app: fx.manifest.app,
|
|
224
|
+
version: fx.manifest.version,
|
|
225
|
+
commit: fx.manifest.commit,
|
|
226
|
+
platform: fx.manifest.platform,
|
|
227
|
+
inputHashes: fx.manifest.inputs,
|
|
228
|
+
cacheKey: "new-cache",
|
|
229
|
+
ops: {},
|
|
230
|
+
}),
|
|
231
|
+
/sealed release cache identity mismatch/i,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
185
235
|
test("manifest or file tampering blocks upload before any mutation", async () => {
|
|
186
236
|
const fx = fixture();
|
|
187
237
|
writeFileSync(fx.installer, "changed");
|
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
|
-
|
|
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));
|