@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
|
@@ -53,15 +53,28 @@ test("allows release invocation from the primary Git checkout", () => {
|
|
|
53
53
|
assert.doesNotThrow(() => assertPrimaryReleaseCheckout(primary));
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
test("
|
|
56
|
+
test("rejects a detached HEAD even in the primary Git checkout", () => {
|
|
57
|
+
const primary = makeRepo();
|
|
58
|
+
git(primary, "checkout", "--detach");
|
|
59
|
+
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => assertPrimaryReleaseCheckout(primary),
|
|
62
|
+
/detached HEAD is forbidden/i,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("reports a dirty tracked file declared as a build input", () => {
|
|
57
67
|
const primary = makeRepo();
|
|
58
68
|
writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), ["src/app.js"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("reports an untracked file declared as a build input", () => {
|
|
74
|
+
const primary = makeRepo();
|
|
59
75
|
writeFileSync(path.join(primary, "src", "new-runtime.js"), "export {};\n");
|
|
60
76
|
|
|
61
|
-
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), [
|
|
62
|
-
"src/app.js",
|
|
63
|
-
"src/new-runtime.js",
|
|
64
|
-
]);
|
|
77
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), ["src/new-runtime.js"]);
|
|
65
78
|
});
|
|
66
79
|
|
|
67
80
|
test("ignores dirty files outside the packaged app input set", () => {
|
|
@@ -73,6 +86,21 @@ test("ignores dirty files outside the packaged app input set", () => {
|
|
|
73
86
|
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), []);
|
|
74
87
|
});
|
|
75
88
|
|
|
89
|
+
test("required release metadata cannot be hidden by build-input exclusions", () => {
|
|
90
|
+
const primary = makeRepo();
|
|
91
|
+
writeFileSync(path.join(primary, "package.json"), "{\"version\":\"2.0.0\"}\n");
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(dirtyBuildInputs(primary, {
|
|
94
|
+
...buildInputs,
|
|
95
|
+
exclude: [...buildInputs.exclude, "package.json"],
|
|
96
|
+
required: ["package.json"],
|
|
97
|
+
}), ["package.json"]);
|
|
98
|
+
assert.deepEqual(dirtyBuildInputs(primary, {
|
|
99
|
+
include: ["package.json"],
|
|
100
|
+
required: ["package.json"],
|
|
101
|
+
}), ["package.json"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
76
104
|
test("checks only build-relevant fields in mixed-purpose JSON manifests", () => {
|
|
77
105
|
const primary = makeRepo();
|
|
78
106
|
writeFileSync(path.join(primary, "package.json"), `${JSON.stringify({ scripts: { bakeoff: "node eval.mjs" } })}\n`);
|
package/build-release.mjs
CHANGED
|
@@ -21,7 +21,8 @@ import path from "node:path";
|
|
|
21
21
|
import { spawn, spawnSync } from "node:child_process";
|
|
22
22
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
23
|
import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
|
|
24
|
-
import {
|
|
24
|
+
import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
|
|
25
|
+
import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
|
|
25
26
|
|
|
26
27
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
27
28
|
const WORKER = path.join(TOOL_ROOT, "release.mjs");
|
|
@@ -41,69 +42,73 @@ for (let i = 0; i < args.length; i += 1) {
|
|
|
41
42
|
}
|
|
42
43
|
if (platform !== "win" && platform !== "mac") fail("--platform must be win or mac");
|
|
43
44
|
|
|
44
|
-
const
|
|
45
|
+
const configPath = path.resolve(configName);
|
|
46
|
+
const invocationRoot = path.dirname(configPath);
|
|
45
47
|
const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
|
|
46
48
|
assertPrimaryReleaseCheckout(repoRoot);
|
|
47
|
-
const relativeConfig = path.relative(repoRoot,
|
|
49
|
+
const relativeConfig = path.relative(repoRoot, configPath).replaceAll("\\", "/");
|
|
48
50
|
if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
|
|
49
|
-
const
|
|
51
|
+
const appRoot = path.dirname(configPath);
|
|
52
|
+
const layout = resolveReleaseLayout({ repoRoot, configPath });
|
|
53
|
+
const vaultRoot = layout.vaultRoot;
|
|
50
54
|
mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
|
|
51
55
|
const lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
|
|
52
56
|
let child = null;
|
|
57
|
+
let suiteSlot;
|
|
58
|
+
let cacheLease;
|
|
59
|
+
let interrupted;
|
|
53
60
|
|
|
54
61
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
55
62
|
process.once(signal, () => {
|
|
56
63
|
if (child?.pid) killTree(child.pid);
|
|
57
|
-
|
|
58
|
-
process.
|
|
64
|
+
interrupted = new Error(`release interrupted by ${signal}`);
|
|
65
|
+
process.exitCode = signal === "SIGINT" ? 130 : 143;
|
|
59
66
|
});
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
try {
|
|
63
|
-
|
|
64
|
-
const commit = git(repoRoot, ["rev-parse",
|
|
70
|
+
throwIfInterrupted();
|
|
71
|
+
const commit = git(repoRoot, ["rev-parse", "HEAD"]);
|
|
65
72
|
const shortCommit = commit.slice(0, 8);
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
mkdirSync(path.dirname(worktree), { recursive: true });
|
|
70
|
-
runChecked("git", ["worktree", "add", "--detach", worktree, commit], repoRoot);
|
|
71
|
-
} else if (git(worktree, ["rev-parse", "HEAD"]) !== commit) {
|
|
72
|
-
fail(`release worktree exists at the wrong commit: ${worktree}`);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const worktreeConfigPath = path.join(worktree, relativeConfig);
|
|
76
|
-
const appRoot = path.dirname(worktreeConfigPath);
|
|
77
|
-
const layout = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) });
|
|
78
|
-
const config = (await import(`${pathToFileURL(worktreeConfigPath).href}?commit=${commit}`)).default;
|
|
73
|
+
const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
|
|
74
|
+
if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
|
|
75
|
+
const config = (await import(`${pathToFileURL(configPath).href}?commit=${commit}`)).default;
|
|
79
76
|
if (!config?.app || !config?.version) fail("release config must expose app and version");
|
|
80
77
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(config.app) || !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(config.version)) {
|
|
81
78
|
fail("app and version must be safe release-id components");
|
|
82
79
|
}
|
|
83
80
|
const target = config.targets?.[platform];
|
|
84
81
|
if (!target?.package) fail(`${config.app} has no ${platform} package command`);
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
const requiredInputs = inputPaths(appRoot, worktreeConfigPath, target);
|
|
82
|
+
const requiredInputs = inputPaths(appRoot, configPath, target);
|
|
83
|
+
const dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, appRoot, target.buildInputs ?? config.buildInputs, requiredInputs), commit);
|
|
84
|
+
if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
|
|
90
85
|
const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
|
|
91
86
|
const inputHashes = hashInputs(requiredInputs);
|
|
92
87
|
const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
|
|
93
88
|
const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
89
|
+
const cacheIdentity = resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
|
|
90
|
+
const cacheKey = cacheIdentity.fingerprint;
|
|
91
|
+
const cacheMode = process.env.RIGHT_RELEASE_CACHE_MODE ?? (platform === "mac" ? "shared" : "legacy");
|
|
92
|
+
if (cacheMode !== "legacy" && cacheMode !== "shared") fail("RIGHT_RELEASE_CACHE_MODE must be legacy or shared");
|
|
93
|
+
const sharedCacheRoot = cacheMode === "shared" ? resolveSharedCacheRoot({ platform, env: process.env }) : undefined;
|
|
94
|
+
const sharedLayout = cacheMode === "shared"
|
|
95
|
+
? resolveCacheLayout({ cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, fingerprint: cacheKey, kind: "release" })
|
|
96
|
+
: undefined;
|
|
97
|
+
if (cacheMode === "shared") assertSccacheVersion(toolVersions.sccache);
|
|
100
98
|
const env = {
|
|
101
99
|
...process.env,
|
|
102
|
-
...releaseEnvironment({ root: vaultRoot, platform, cacheKey, kind: "release" }),
|
|
100
|
+
...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
|
|
103
101
|
RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
|
|
104
102
|
RIGHT_RELEASE_APP_ROOT: layout.appRoot,
|
|
105
103
|
};
|
|
106
|
-
if (
|
|
104
|
+
if (cacheMode === "legacy") delete env.RIGHT_RELEASE_CACHE_OWNER;
|
|
105
|
+
if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
|
|
106
|
+
if (cacheMode === "legacy" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
|
|
107
|
+
if (cacheMode === "shared") {
|
|
108
|
+
suiteSlot = acquireSuiteBuildSlot({ layout: sharedLayout, pid: process.pid, argv: process.argv, waitMs: Number(process.env.BUILD_GUARD_WAIT_SECS ?? 900) * 1000 });
|
|
109
|
+
cacheLease = acquireCacheLease({ layout: sharedLayout, pid: process.pid, argv: process.argv });
|
|
110
|
+
}
|
|
111
|
+
throwIfInterrupted();
|
|
107
112
|
const releaseId = `${config.app}-${config.version}-${shortCommit}`;
|
|
108
113
|
const platformDir = platform === "win" ? "windows" : "mac";
|
|
109
114
|
const buildRoot = path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
|
|
@@ -128,9 +133,19 @@ try {
|
|
|
128
133
|
commit,
|
|
129
134
|
platform,
|
|
130
135
|
requiredInputs,
|
|
136
|
+
inputHashes,
|
|
137
|
+
cacheKey,
|
|
131
138
|
ops: {
|
|
132
139
|
preflight: async () => {
|
|
133
|
-
|
|
140
|
+
throwIfInterrupted();
|
|
141
|
+
if (cacheMode === "shared") {
|
|
142
|
+
const policy = readCachePolicy(process.env);
|
|
143
|
+
const snapshot = inspectCache({ cacheRoot: sharedCacheRoot });
|
|
144
|
+
const plan = planCachePrune({ snapshot, policy, protectedEntryIds: new Set([sharedLayout.id]) });
|
|
145
|
+
applyCachePrune({ plan, cacheRoot: sharedCacheRoot, dryRun: false });
|
|
146
|
+
const volumes = inspectWriteVolumes({ repoRoot, vaultRoot, cacheRoot: sharedCacheRoot });
|
|
147
|
+
assertWriteVolumeFloors({ volumes, policy, configuredRepoMinimumBytes: Math.max(Number(target.preflight?.minFreeGb ?? 25), 25) * 1024 ** 3 });
|
|
148
|
+
} else assertDiskSpace(repoRoot, target.preflight?.minFreeGb ?? 20);
|
|
134
149
|
assertExecutables(["git", config.packageManager ?? "pnpm", "cargo", "rustc", ...(target.preflight?.executables ?? [])]);
|
|
135
150
|
for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
|
|
136
151
|
for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(appRoot, command.cwd ?? "."), env);
|
|
@@ -141,19 +156,21 @@ try {
|
|
|
141
156
|
checkpoint(stateRoot, "preflight_complete");
|
|
142
157
|
},
|
|
143
158
|
prepare: async () => {
|
|
159
|
+
throwIfInterrupted();
|
|
144
160
|
const cacheTarget = env.CARGO_TARGET_DIR;
|
|
145
161
|
const targetLink = path.join(appRoot, "src-tauri", "target");
|
|
146
162
|
mkdirSync(cacheTarget, { recursive: true });
|
|
147
163
|
if (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
|
|
148
164
|
else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
|
|
149
|
-
fail(`
|
|
165
|
+
fail(`primary checkout target exists and is not the release cache link: ${targetLink}`);
|
|
150
166
|
}
|
|
151
167
|
await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget);
|
|
152
168
|
},
|
|
153
169
|
build: async () => {
|
|
170
|
+
throwIfInterrupted();
|
|
154
171
|
await runProgress(
|
|
155
172
|
process.execPath,
|
|
156
|
-
[WORKER, "--config",
|
|
173
|
+
[WORKER, "--config", configPath, "--platform", platform, "--no-upload"],
|
|
157
174
|
appRoot,
|
|
158
175
|
env,
|
|
159
176
|
[env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
|
|
@@ -163,14 +180,23 @@ try {
|
|
|
163
180
|
checkpoint(stateRoot, "hardened");
|
|
164
181
|
},
|
|
165
182
|
seal: async ({ sealedDir }) => {
|
|
166
|
-
sealRelease({ configRoot:
|
|
183
|
+
sealRelease({ configRoot: appRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey });
|
|
184
|
+
verifySealedRelease(sealedDir);
|
|
185
|
+
if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
|
|
167
186
|
checkpoint(stateRoot, "sealed");
|
|
168
187
|
},
|
|
169
188
|
},
|
|
170
189
|
});
|
|
190
|
+
if (cacheMode === "shared") {
|
|
191
|
+
const snapshot = inspectCache({ cacheRoot: sharedCacheRoot });
|
|
192
|
+
const plan = planCachePrune({ snapshot, policy: readCachePolicy(process.env), protectedEntryIds: new Set([sharedLayout.id]) });
|
|
193
|
+
applyCachePrune({ plan, cacheRoot: sharedCacheRoot, dryRun: false });
|
|
194
|
+
}
|
|
171
195
|
console.log(`right-release build: ${result.resumed ? "resumed" : "sealed"} ${result.releaseId}`);
|
|
172
196
|
console.log(`sealed: ${result.sealedDir}`);
|
|
173
197
|
} finally {
|
|
198
|
+
cacheLease?.release();
|
|
199
|
+
suiteSlot?.release();
|
|
174
200
|
lock.release();
|
|
175
201
|
}
|
|
176
202
|
|
|
@@ -188,20 +214,19 @@ function inputPaths(appRoot, configPath, target) {
|
|
|
188
214
|
return required;
|
|
189
215
|
}
|
|
190
216
|
|
|
191
|
-
function repoBuildInputs(repoRoot, appRoot,
|
|
217
|
+
function repoBuildInputs(repoRoot, appRoot, configured, requiredInputs) {
|
|
192
218
|
if (!Array.isArray(configured?.include) || configured.include.length === 0) {
|
|
193
219
|
fail("release config must declare non-empty buildInputs.include paths");
|
|
194
220
|
}
|
|
195
221
|
const appPrefix = path.relative(repoRoot, appRoot).replaceAll("\\", "/");
|
|
196
|
-
const qualify = (pattern) => [appPrefix, pattern.replaceAll("\\", "/").replace(/^\.\//, "")].filter(Boolean).join("/");
|
|
222
|
+
const qualify = (pattern) => path.posix.normalize([appPrefix, pattern.replaceAll("\\", "/").replace(/^\.\//, "")].filter(Boolean).join("/"));
|
|
223
|
+
const required = requiredInputs
|
|
224
|
+
.map((file) => path.relative(repoRoot, file).replaceAll("\\", "/"))
|
|
225
|
+
.filter((file) => file && file !== ".." && !file.startsWith("../") && !path.isAbsolute(file));
|
|
197
226
|
return {
|
|
198
|
-
include:
|
|
199
|
-
relativeConfig,
|
|
200
|
-
qualify("package.json"),
|
|
201
|
-
qualify("pnpm-lock.yaml"),
|
|
202
|
-
...configured.include.map(qualify),
|
|
203
|
-
],
|
|
227
|
+
include: configured.include.map(qualify),
|
|
204
228
|
exclude: (configured.exclude ?? []).map(qualify),
|
|
229
|
+
required,
|
|
205
230
|
json: Object.fromEntries(Object.entries(configured.json ?? {}).map(([file, fields]) => [qualify(file), fields])),
|
|
206
231
|
};
|
|
207
232
|
}
|
|
@@ -374,6 +399,11 @@ function commandExists(name) {
|
|
|
374
399
|
return probe.status === 0;
|
|
375
400
|
}
|
|
376
401
|
|
|
402
|
+
function assertSccacheVersion(version) {
|
|
403
|
+
const match = String(version ?? "").match(/sccache\s+(\d+)\.(\d+)\.(\d+)/i);
|
|
404
|
+
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");
|
|
405
|
+
}
|
|
406
|
+
|
|
377
407
|
function commandOutput(cmd, runArgs) {
|
|
378
408
|
try {
|
|
379
409
|
return commandOutputPortable(cmd, runArgs);
|
|
@@ -419,5 +449,7 @@ function usage(code) {
|
|
|
419
449
|
|
|
420
450
|
function fail(message) {
|
|
421
451
|
console.error(`right-release build: ${message}`);
|
|
422
|
-
|
|
452
|
+
throw new Error(message);
|
|
423
453
|
}
|
|
454
|
+
|
|
455
|
+
function throwIfInterrupted() { if (interrupted) throw interrupted; }
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
});
|
|
25
|
+
|
|
26
|
+
test("production builds package from the real primary app checkout", () => {
|
|
27
|
+
assert.match(source, /const configPath = path\.resolve\(configName\)/);
|
|
28
|
+
assert.match(source, /const appRoot = path\.dirname\(configPath\)/);
|
|
29
|
+
assert.match(source, /\[WORKER, "--config", configPath, "--platform", platform, "--no-upload"\]/);
|
|
30
|
+
assert.match(source, /sealRelease\(\{ configRoot: appRoot,/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("production builds never create or use an internal Git worktree", () => {
|
|
34
|
+
assert.doesNotMatch(source, /git", \["worktree", "add"/);
|
|
35
|
+
assert.doesNotMatch(source, /path\.join\(vaultRoot, "worktrees"/);
|
|
36
|
+
assert.doesNotMatch(source, /worktreeConfigPath/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("dirty release config is rejected before the config module is imported", () => {
|
|
40
|
+
const guard = source.indexOf("dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit)");
|
|
41
|
+
const imported = source.indexOf("await import(");
|
|
42
|
+
assert.ok(guard >= 0, "config cleanliness guard must exist");
|
|
43
|
+
assert.ok(guard < imported, "config cleanliness guard must run before module import");
|
|
44
|
+
});
|
|
@@ -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
|
+
});
|