@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,96 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
|
|
8
|
+
import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
|
|
9
|
+
|
|
10
|
+
function git(cwd, ...args) {
|
|
11
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeRepo() {
|
|
15
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "right-release-invocation-"));
|
|
16
|
+
git(root, "init", "--initial-branch", "main");
|
|
17
|
+
git(root, "config", "user.email", "release-test@example.com");
|
|
18
|
+
git(root, "config", "user.name", "Release Test");
|
|
19
|
+
mkdirSync(path.join(root, "src"));
|
|
20
|
+
mkdirSync(path.join(root, "tests"));
|
|
21
|
+
mkdirSync(path.join(root, "docs"));
|
|
22
|
+
writeFileSync(path.join(root, "package.json"), "{}\n");
|
|
23
|
+
writeFileSync(path.join(root, "src", "app.js"), "export const app = true;\n");
|
|
24
|
+
writeFileSync(path.join(root, "tests", "app.test.js"), "// test\n");
|
|
25
|
+
writeFileSync(path.join(root, "docs", "notes.md"), "notes\n");
|
|
26
|
+
git(root, "add", ".");
|
|
27
|
+
git(root, "commit", "-m", "initial");
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const buildInputs = {
|
|
32
|
+
include: ["package.json", "src/**"],
|
|
33
|
+
exclude: ["**/*.test.*", "**/tests/**"],
|
|
34
|
+
json: {
|
|
35
|
+
"package.json": [["dependencies"], ["scripts", "build"]],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
test("rejects release invocation from a linked Git worktree", () => {
|
|
40
|
+
const primary = makeRepo();
|
|
41
|
+
const linked = path.join(path.dirname(primary), `${path.basename(primary)}-linked`);
|
|
42
|
+
git(primary, "worktree", "add", "--detach", linked);
|
|
43
|
+
|
|
44
|
+
assert.throws(
|
|
45
|
+
() => assertPrimaryReleaseCheckout(linked),
|
|
46
|
+
/must be invoked from the primary Git worktree/i,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("allows release invocation from the primary Git checkout", () => {
|
|
51
|
+
const primary = makeRepo();
|
|
52
|
+
|
|
53
|
+
assert.doesNotThrow(() => assertPrimaryReleaseCheckout(primary));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("reports dirty files that can change the packaged app", () => {
|
|
57
|
+
const primary = makeRepo();
|
|
58
|
+
writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
|
|
59
|
+
writeFileSync(path.join(primary, "src", "new-runtime.js"), "export {};\n");
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), [
|
|
62
|
+
"src/app.js",
|
|
63
|
+
"src/new-runtime.js",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("ignores dirty files outside the packaged app input set", () => {
|
|
68
|
+
const primary = makeRepo();
|
|
69
|
+
writeFileSync(path.join(primary, "docs", "notes.md"), "changed notes\n");
|
|
70
|
+
writeFileSync(path.join(primary, "tests", "app.test.js"), "// changed test\n");
|
|
71
|
+
writeFileSync(path.join(primary, "scratch.txt"), "evaluation output\n");
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), []);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("checks only build-relevant fields in mixed-purpose JSON manifests", () => {
|
|
77
|
+
const primary = makeRepo();
|
|
78
|
+
writeFileSync(path.join(primary, "package.json"), `${JSON.stringify({ scripts: { bakeoff: "node eval.mjs" } })}\n`);
|
|
79
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), []);
|
|
80
|
+
|
|
81
|
+
writeFileSync(path.join(primary, "package.json"), `${JSON.stringify({ dependencies: { tauri: "2.0.0" } })}\n`);
|
|
82
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), ["package.json"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("does not report a locally dirty file whose bytes already equal the release commit", () => {
|
|
86
|
+
const primary = makeRepo();
|
|
87
|
+
const localHead = git(primary, "rev-parse", "HEAD");
|
|
88
|
+
writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
|
|
89
|
+
git(primary, "add", "src/app.js");
|
|
90
|
+
git(primary, "commit", "-m", "release change");
|
|
91
|
+
const releaseCommit = git(primary, "rev-parse", "HEAD");
|
|
92
|
+
git(primary, "checkout", localHead);
|
|
93
|
+
writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs, releaseCommit), []);
|
|
96
|
+
});
|
package/build-release.mjs
CHANGED
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { spawn, spawnSync } from "node:child_process";
|
|
22
22
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
|
-
import {
|
|
23
|
+
import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.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";
|
|
24
26
|
|
|
25
27
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
26
28
|
const WORKER = path.join(TOOL_ROOT, "release.mjs");
|
|
@@ -42,22 +44,27 @@ if (platform !== "win" && platform !== "mac") fail("--platform must be win or ma
|
|
|
42
44
|
|
|
43
45
|
const invocationRoot = path.dirname(path.resolve(configName));
|
|
44
46
|
const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
|
|
47
|
+
assertPrimaryReleaseCheckout(repoRoot);
|
|
45
48
|
const relativeConfig = path.relative(repoRoot, path.resolve(configName)).replaceAll("\\", "/");
|
|
46
49
|
if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
|
|
47
50
|
const vaultRoot = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) }).vaultRoot;
|
|
48
51
|
mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
|
|
49
52
|
const lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
|
|
50
53
|
let child = null;
|
|
54
|
+
let suiteSlot;
|
|
55
|
+
let cacheLease;
|
|
56
|
+
let interrupted;
|
|
51
57
|
|
|
52
58
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
53
59
|
process.once(signal, () => {
|
|
54
60
|
if (child?.pid) killTree(child.pid);
|
|
55
|
-
|
|
56
|
-
process.
|
|
61
|
+
interrupted = new Error(`release interrupted by ${signal}`);
|
|
62
|
+
process.exitCode = signal === "SIGINT" ? 130 : 143;
|
|
57
63
|
});
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
try {
|
|
67
|
+
throwIfInterrupted();
|
|
61
68
|
if (!dryRun) git(repoRoot, ["fetch", "origin", "main"]);
|
|
62
69
|
const commit = git(repoRoot, ["rev-parse", dryRun ? "HEAD" : "origin/main"]);
|
|
63
70
|
const shortCommit = commit.slice(0, 8);
|
|
@@ -80,24 +87,36 @@ try {
|
|
|
80
87
|
}
|
|
81
88
|
const target = config.targets?.[platform];
|
|
82
89
|
if (!target?.package) fail(`${config.app} has no ${platform} package command`);
|
|
90
|
+
const dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, invocationRoot, relativeConfig, target.buildInputs ?? config.buildInputs), commit);
|
|
91
|
+
if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
|
|
83
92
|
const requiredInputs = inputPaths(appRoot, worktreeConfigPath, target);
|
|
84
93
|
const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
|
|
85
94
|
const inputHashes = hashInputs(requiredInputs);
|
|
86
95
|
const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
|
|
87
96
|
const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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);
|
|
94
106
|
const env = {
|
|
95
107
|
...process.env,
|
|
96
|
-
...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 }),
|
|
97
109
|
RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
|
|
98
110
|
RIGHT_RELEASE_APP_ROOT: layout.appRoot,
|
|
99
111
|
};
|
|
100
|
-
if (
|
|
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();
|
|
101
120
|
const releaseId = `${config.app}-${config.version}-${shortCommit}`;
|
|
102
121
|
const platformDir = platform === "win" ? "windows" : "mac";
|
|
103
122
|
const buildRoot = path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
|
|
@@ -124,7 +143,15 @@ try {
|
|
|
124
143
|
requiredInputs,
|
|
125
144
|
ops: {
|
|
126
145
|
preflight: async () => {
|
|
127
|
-
|
|
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);
|
|
128
155
|
assertExecutables(["git", config.packageManager ?? "pnpm", "cargo", "rustc", ...(target.preflight?.executables ?? [])]);
|
|
129
156
|
for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
|
|
130
157
|
for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(appRoot, command.cwd ?? "."), env);
|
|
@@ -135,6 +162,7 @@ try {
|
|
|
135
162
|
checkpoint(stateRoot, "preflight_complete");
|
|
136
163
|
},
|
|
137
164
|
prepare: async () => {
|
|
165
|
+
throwIfInterrupted();
|
|
138
166
|
const cacheTarget = env.CARGO_TARGET_DIR;
|
|
139
167
|
const targetLink = path.join(appRoot, "src-tauri", "target");
|
|
140
168
|
mkdirSync(cacheTarget, { recursive: true });
|
|
@@ -145,6 +173,7 @@ try {
|
|
|
145
173
|
await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget);
|
|
146
174
|
},
|
|
147
175
|
build: async () => {
|
|
176
|
+
throwIfInterrupted();
|
|
148
177
|
await runProgress(
|
|
149
178
|
process.execPath,
|
|
150
179
|
[WORKER, "--config", worktreeConfigPath, "--platform", platform, "--no-upload"],
|
|
@@ -158,13 +187,22 @@ try {
|
|
|
158
187
|
},
|
|
159
188
|
seal: async ({ sealedDir }) => {
|
|
160
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 });
|
|
161
192
|
checkpoint(stateRoot, "sealed");
|
|
162
193
|
},
|
|
163
194
|
},
|
|
164
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
|
+
}
|
|
165
201
|
console.log(`right-release build: ${result.resumed ? "resumed" : "sealed"} ${result.releaseId}`);
|
|
166
202
|
console.log(`sealed: ${result.sealedDir}`);
|
|
167
203
|
} finally {
|
|
204
|
+
cacheLease?.release();
|
|
205
|
+
suiteSlot?.release();
|
|
168
206
|
lock.release();
|
|
169
207
|
}
|
|
170
208
|
|
|
@@ -182,6 +220,24 @@ function inputPaths(appRoot, configPath, target) {
|
|
|
182
220
|
return required;
|
|
183
221
|
}
|
|
184
222
|
|
|
223
|
+
function repoBuildInputs(repoRoot, appRoot, relativeConfig, configured) {
|
|
224
|
+
if (!Array.isArray(configured?.include) || configured.include.length === 0) {
|
|
225
|
+
fail("release config must declare non-empty buildInputs.include paths");
|
|
226
|
+
}
|
|
227
|
+
const appPrefix = path.relative(repoRoot, appRoot).replaceAll("\\", "/");
|
|
228
|
+
const qualify = (pattern) => [appPrefix, pattern.replaceAll("\\", "/").replace(/^\.\//, "")].filter(Boolean).join("/");
|
|
229
|
+
return {
|
|
230
|
+
include: [
|
|
231
|
+
relativeConfig,
|
|
232
|
+
qualify("package.json"),
|
|
233
|
+
qualify("pnpm-lock.yaml"),
|
|
234
|
+
...configured.include.map(qualify),
|
|
235
|
+
],
|
|
236
|
+
exclude: (configured.exclude ?? []).map(qualify),
|
|
237
|
+
json: Object.fromEntries(Object.entries(configured.json ?? {}).map(([file, fields]) => [qualify(file), fields])),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
185
241
|
function hashInputs(files) {
|
|
186
242
|
return Object.fromEntries(files.map((file) => [path.relative(path.dirname(path.dirname(file)), file), hashFile(file)]));
|
|
187
243
|
}
|
|
@@ -350,6 +406,11 @@ function commandExists(name) {
|
|
|
350
406
|
return probe.status === 0;
|
|
351
407
|
}
|
|
352
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
|
+
|
|
353
414
|
function commandOutput(cmd, runArgs) {
|
|
354
415
|
try {
|
|
355
416
|
return commandOutputPortable(cmd, runArgs);
|
|
@@ -395,5 +456,7 @@ function usage(code) {
|
|
|
395
456
|
|
|
396
457
|
function fail(message) {
|
|
397
458
|
console.error(`right-release build: ${message}`);
|
|
398
|
-
|
|
459
|
+
throw new Error(message);
|
|
399
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
|
+
});
|