@rightkit/release 0.2.37 → 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-invocation-contract.test.mjs +96 -0
- package/build-release.mjs +76 -13
- package/build-release.test.mjs +24 -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-cli-contract.test.mjs +12 -0
- package/release-invocation.mjs +77 -0
- package/release-state.mjs +19 -2
- package/release-state.test.mjs +16 -0
- package/release.mjs +27 -1
- package/right-suite-contract.test.mjs +1 -1
- package/rightkit-versions.json +1 -1
- package/upload-release.mjs +2 -0
|
@@ -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.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": {
|
|
@@ -34,6 +34,18 @@ test("build is tier-neutral and rejects a tier before touching a repository", ()
|
|
|
34
34
|
assert.match(result.stderr, /build is tier-neutral/i);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
test("build enforces primary-checkout and build-input cleanliness guards", () => {
|
|
38
|
+
const source = readFileSync(build, "utf8");
|
|
39
|
+
assert.match(source, /assertPrimaryReleaseCheckout\(repoRoot\)/);
|
|
40
|
+
assert.match(source, /dirtyBuildInputs\(repoRoot,/);
|
|
41
|
+
assert.match(source, /dirty files can change the packaged app/i);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("upload enforces the same primary-checkout vault", () => {
|
|
45
|
+
const source = readFileSync(upload, "utf8");
|
|
46
|
+
assert.match(source, /assertPrimaryReleaseCheckout\(repoRoot\)/);
|
|
47
|
+
});
|
|
48
|
+
|
|
37
49
|
test("upload requires an explicit patch or update tier before reading a release", () => {
|
|
38
50
|
const result = spawnSync(process.execPath, [upload, "--platform", "win", "--release", "fixture-1.0.0-deadbeef"], {
|
|
39
51
|
cwd: mkdtempSync(path.join(os.tmpdir(), "right-upload-cli-")),
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function git(cwd, args) {
|
|
6
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
|
|
7
|
+
if (result.status !== 0) {
|
|
8
|
+
throw new Error(`git ${args.join(" ")} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
9
|
+
}
|
|
10
|
+
return result.stdout;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function canonical(file) {
|
|
14
|
+
return path.normalize(realpathSync(file)).toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function assertPrimaryReleaseCheckout(repoRoot) {
|
|
18
|
+
const worktrees = git(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
19
|
+
const primary = worktrees.match(/^worktree (.+)$/m)?.[1];
|
|
20
|
+
if (!primary) throw new Error("unable to resolve the primary Git worktree");
|
|
21
|
+
if (canonical(repoRoot) !== canonical(primary)) {
|
|
22
|
+
throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pathspec(pattern, exclude = false) {
|
|
27
|
+
const normalized = pattern.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
28
|
+
return `:(top,${exclude ? "exclude," : ""}glob)${normalized}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function jsonProjectionChanged(repoRoot, file, fields, baseline) {
|
|
32
|
+
try {
|
|
33
|
+
const working = JSON.parse(readFileSync(path.join(repoRoot, file), "utf8"));
|
|
34
|
+
const committed = JSON.parse(git(repoRoot, ["show", `${baseline}:${file}`]));
|
|
35
|
+
const project = (value) => fields.map((segments) => segments.reduce((current, segment) => current?.[segment], value));
|
|
36
|
+
return JSON.stringify(project(working)) !== JSON.stringify(project(committed));
|
|
37
|
+
} catch {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fileChangedFromBaseline(repoRoot, file, baseline) {
|
|
43
|
+
const workingPath = path.join(repoRoot, file);
|
|
44
|
+
if (!existsSync(workingPath)) return true;
|
|
45
|
+
try {
|
|
46
|
+
const workingHash = git(repoRoot, ["hash-object", "--", file]).trim();
|
|
47
|
+
const baselineHash = git(repoRoot, ["rev-parse", `${baseline}:${file}`]).trim();
|
|
48
|
+
return workingHash !== baselineHash;
|
|
49
|
+
} catch {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function dirtyBuildInputs(repoRoot, inputs, baseline = "HEAD") {
|
|
55
|
+
if (!Array.isArray(inputs?.include) || inputs.include.length === 0) {
|
|
56
|
+
throw new Error("release config must declare non-empty buildInputs.include paths");
|
|
57
|
+
}
|
|
58
|
+
const specs = [
|
|
59
|
+
...inputs.include.map((pattern) => pathspec(pattern)),
|
|
60
|
+
...(inputs.exclude ?? []).map((pattern) => pathspec(pattern, true)),
|
|
61
|
+
];
|
|
62
|
+
const output = git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...specs]);
|
|
63
|
+
const records = output.split("\0");
|
|
64
|
+
const files = [];
|
|
65
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
66
|
+
const record = records[index];
|
|
67
|
+
if (!record) continue;
|
|
68
|
+
const status = record.slice(0, 2);
|
|
69
|
+
files.push(record.slice(3).replaceAll("\\", "/"));
|
|
70
|
+
if (/[RC]/.test(status)) index += 1;
|
|
71
|
+
}
|
|
72
|
+
return [...new Set(files)]
|
|
73
|
+
.filter((file) => inputs.json?.[file]
|
|
74
|
+
? jsonProjectionChanged(repoRoot, file, inputs.json[file], baseline)
|
|
75
|
+
: fileChangedFromBaseline(repoRoot, file, baseline))
|
|
76
|
+
.sort();
|
|
77
|
+
}
|
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
|
|
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
|
}
|
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));
|
|
@@ -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.
|
|
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");
|
package/rightkit-versions.json
CHANGED
package/upload-release.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, wr
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
7
8
|
import { runUploadStateMachine, verifySealedRelease } from "./release-state.mjs";
|
|
8
9
|
|
|
9
10
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -34,6 +35,7 @@ if (!releaseId) fail("--release is required");
|
|
|
34
35
|
if (platform !== "win" && platform !== "mac") fail("--platform must be win or mac");
|
|
35
36
|
|
|
36
37
|
const repoRoot = git(process.cwd(), ["rev-parse", "--show-toplevel"]);
|
|
38
|
+
assertPrimaryReleaseCheckout(repoRoot);
|
|
37
39
|
const platformDir = platform === "win" ? "windows" : "mac";
|
|
38
40
|
const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir);
|
|
39
41
|
const sealed = verifySealedRelease(sealedDir);
|