@rightkit/release 0.2.39 → 0.2.41
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 +31 -35
- package/build-release.test.mjs +26 -0
- package/package.json +1 -1
- package/release-invocation.mjs +21 -7
- package/release-state.mjs +7 -0
- package/release-state.test.mjs +34 -0
- package/right-suite-contract.test.mjs +78 -11
- package/rightkit-versions.json +1 -1
- package/target-bridge.mjs +119 -0
- package/target-bridge.test.mjs +155 -0
|
@@ -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
|
@@ -23,6 +23,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
23
23
|
import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
|
|
24
24
|
import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
|
|
25
25
|
import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
|
|
26
|
+
import { createTargetBridge } from "./target-bridge.mjs";
|
|
26
27
|
|
|
27
28
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
28
29
|
const WORKER = path.join(TOOL_ROOT, "release.mjs");
|
|
@@ -42,12 +43,15 @@ for (let i = 0; i < args.length; i += 1) {
|
|
|
42
43
|
}
|
|
43
44
|
if (platform !== "win" && platform !== "mac") fail("--platform must be win or mac");
|
|
44
45
|
|
|
45
|
-
const
|
|
46
|
+
const configPath = path.resolve(configName);
|
|
47
|
+
const invocationRoot = path.dirname(configPath);
|
|
46
48
|
const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
|
|
47
49
|
assertPrimaryReleaseCheckout(repoRoot);
|
|
48
|
-
const relativeConfig = path.relative(repoRoot,
|
|
50
|
+
const relativeConfig = path.relative(repoRoot, configPath).replaceAll("\\", "/");
|
|
49
51
|
if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
|
|
50
|
-
const
|
|
52
|
+
const appRoot = path.dirname(configPath);
|
|
53
|
+
const layout = resolveReleaseLayout({ repoRoot, configPath });
|
|
54
|
+
const vaultRoot = layout.vaultRoot;
|
|
51
55
|
mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
|
|
52
56
|
const lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
|
|
53
57
|
let child = null;
|
|
@@ -65,31 +69,20 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
|
65
69
|
|
|
66
70
|
try {
|
|
67
71
|
throwIfInterrupted();
|
|
68
|
-
|
|
69
|
-
const commit = git(repoRoot, ["rev-parse", dryRun ? "HEAD" : "origin/main"]);
|
|
72
|
+
const commit = git(repoRoot, ["rev-parse", "HEAD"]);
|
|
70
73
|
const shortCommit = commit.slice(0, 8);
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
mkdirSync(path.dirname(worktree), { recursive: true });
|
|
75
|
-
runChecked("git", ["worktree", "add", "--detach", worktree, commit], repoRoot);
|
|
76
|
-
} else if (git(worktree, ["rev-parse", "HEAD"]) !== commit) {
|
|
77
|
-
fail(`release worktree exists at the wrong commit: ${worktree}`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const worktreeConfigPath = path.join(worktree, relativeConfig);
|
|
81
|
-
const appRoot = path.dirname(worktreeConfigPath);
|
|
82
|
-
const layout = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) });
|
|
83
|
-
const config = (await import(`${pathToFileURL(worktreeConfigPath).href}?commit=${commit}`)).default;
|
|
74
|
+
const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
|
|
75
|
+
if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
|
|
76
|
+
const config = (await import(`${pathToFileURL(configPath).href}?commit=${commit}`)).default;
|
|
84
77
|
if (!config?.app || !config?.version) fail("release config must expose app and version");
|
|
85
78
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(config.app) || !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(config.version)) {
|
|
86
79
|
fail("app and version must be safe release-id components");
|
|
87
80
|
}
|
|
88
81
|
const target = config.targets?.[platform];
|
|
89
82
|
if (!target?.package) fail(`${config.app} has no ${platform} package command`);
|
|
90
|
-
const
|
|
83
|
+
const requiredInputs = inputPaths(appRoot, configPath, target);
|
|
84
|
+
const dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, appRoot, target.buildInputs ?? config.buildInputs, requiredInputs), commit);
|
|
91
85
|
if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
|
|
92
|
-
const requiredInputs = inputPaths(appRoot, worktreeConfigPath, target);
|
|
93
86
|
const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
|
|
94
87
|
const inputHashes = hashInputs(requiredInputs);
|
|
95
88
|
const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
|
|
@@ -133,14 +126,18 @@ try {
|
|
|
133
126
|
tools: toolVersions,
|
|
134
127
|
createdAt: new Date().toISOString(),
|
|
135
128
|
};
|
|
129
|
+
const targetLink = path.join(appRoot, "src-tauri", "target");
|
|
130
|
+
const targetBridge = createTargetBridge({ link: targetLink, target: env.CARGO_TARGET_DIR });
|
|
136
131
|
|
|
137
|
-
const result = await runBuildStateMachine({
|
|
132
|
+
const result = await targetBridge.run(async () => runBuildStateMachine({
|
|
138
133
|
root: repoRoot,
|
|
139
134
|
app: config.app,
|
|
140
135
|
version: config.version,
|
|
141
136
|
commit,
|
|
142
137
|
platform,
|
|
143
138
|
requiredInputs,
|
|
139
|
+
inputHashes,
|
|
140
|
+
cacheKey,
|
|
144
141
|
ops: {
|
|
145
142
|
preflight: async () => {
|
|
146
143
|
throwIfInterrupted();
|
|
@@ -164,11 +161,11 @@ try {
|
|
|
164
161
|
prepare: async () => {
|
|
165
162
|
throwIfInterrupted();
|
|
166
163
|
const cacheTarget = env.CARGO_TARGET_DIR;
|
|
167
|
-
const targetLink = path.join(appRoot, "src-tauri", "target");
|
|
168
164
|
mkdirSync(cacheTarget, { recursive: true });
|
|
169
|
-
if (
|
|
165
|
+
if (cacheMode === "shared") targetBridge.ensure();
|
|
166
|
+
else if (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
|
|
170
167
|
else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
|
|
171
|
-
fail(`
|
|
168
|
+
fail(`primary checkout target exists and is not the release cache link: ${targetLink}`);
|
|
172
169
|
}
|
|
173
170
|
await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget);
|
|
174
171
|
},
|
|
@@ -176,7 +173,7 @@ try {
|
|
|
176
173
|
throwIfInterrupted();
|
|
177
174
|
await runProgress(
|
|
178
175
|
process.execPath,
|
|
179
|
-
[WORKER, "--config",
|
|
176
|
+
[WORKER, "--config", configPath, "--platform", platform, "--no-upload"],
|
|
180
177
|
appRoot,
|
|
181
178
|
env,
|
|
182
179
|
[env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
|
|
@@ -186,13 +183,13 @@ try {
|
|
|
186
183
|
checkpoint(stateRoot, "hardened");
|
|
187
184
|
},
|
|
188
185
|
seal: async ({ sealedDir }) => {
|
|
189
|
-
sealRelease({ configRoot:
|
|
186
|
+
sealRelease({ configRoot: appRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey });
|
|
190
187
|
verifySealedRelease(sealedDir);
|
|
191
188
|
if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
|
|
192
189
|
checkpoint(stateRoot, "sealed");
|
|
193
190
|
},
|
|
194
191
|
},
|
|
195
|
-
});
|
|
192
|
+
}));
|
|
196
193
|
if (cacheMode === "shared") {
|
|
197
194
|
const snapshot = inspectCache({ cacheRoot: sharedCacheRoot });
|
|
198
195
|
const plan = planCachePrune({ snapshot, policy: readCachePolicy(process.env), protectedEntryIds: new Set([sharedLayout.id]) });
|
|
@@ -220,20 +217,19 @@ function inputPaths(appRoot, configPath, target) {
|
|
|
220
217
|
return required;
|
|
221
218
|
}
|
|
222
219
|
|
|
223
|
-
function repoBuildInputs(repoRoot, appRoot,
|
|
220
|
+
function repoBuildInputs(repoRoot, appRoot, configured, requiredInputs) {
|
|
224
221
|
if (!Array.isArray(configured?.include) || configured.include.length === 0) {
|
|
225
222
|
fail("release config must declare non-empty buildInputs.include paths");
|
|
226
223
|
}
|
|
227
224
|
const appPrefix = path.relative(repoRoot, appRoot).replaceAll("\\", "/");
|
|
228
|
-
const qualify = (pattern) => [appPrefix, pattern.replaceAll("\\", "/").replace(/^\.\//, "")].filter(Boolean).join("/");
|
|
225
|
+
const qualify = (pattern) => path.posix.normalize([appPrefix, pattern.replaceAll("\\", "/").replace(/^\.\//, "")].filter(Boolean).join("/"));
|
|
226
|
+
const required = requiredInputs
|
|
227
|
+
.map((file) => path.relative(repoRoot, file).replaceAll("\\", "/"))
|
|
228
|
+
.filter((file) => file && file !== ".." && !file.startsWith("../") && !path.isAbsolute(file));
|
|
229
229
|
return {
|
|
230
|
-
include:
|
|
231
|
-
relativeConfig,
|
|
232
|
-
qualify("package.json"),
|
|
233
|
-
qualify("pnpm-lock.yaml"),
|
|
234
|
-
...configured.include.map(qualify),
|
|
235
|
-
],
|
|
230
|
+
include: configured.include.map(qualify),
|
|
236
231
|
exclude: (configured.exclude ?? []).map(qualify),
|
|
232
|
+
required,
|
|
237
233
|
json: Object.fromEntries(Object.entries(configured.json ?? {}).map(([file, fields]) => [qualify(file), fields])),
|
|
238
234
|
};
|
|
239
235
|
}
|
package/build-release.test.mjs
CHANGED
|
@@ -22,3 +22,29 @@ test("macOS builds default to Cache V2 while explicit legacy mode remains availa
|
|
|
22
22
|
assert.match(source, /assertPrimaryReleaseCheckout\(repoRoot\)/);
|
|
23
23
|
assert.match(source, /dirtyBuildInputs\(repoRoot/);
|
|
24
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
|
+
});
|
|
45
|
+
|
|
46
|
+
test("shared Cache V2 target bridge is owned by a finally-cleaned lifecycle", () => {
|
|
47
|
+
assert.match(source, /createTargetBridge\(\{ link: targetLink, target: env\.CARGO_TARGET_DIR \}\)/);
|
|
48
|
+
assert.match(source, /targetBridge\.run\(.*runBuildStateMachine/s);
|
|
49
|
+
assert.match(source, /if \(cacheMode === "shared"\) targetBridge\.ensure\(\)/);
|
|
50
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.41",
|
|
4
4
|
"description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-invocation.mjs
CHANGED
|
@@ -21,6 +21,8 @@ export function assertPrimaryReleaseCheckout(repoRoot) {
|
|
|
21
21
|
if (canonical(repoRoot) !== canonical(primary)) {
|
|
22
22
|
throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
|
|
23
23
|
}
|
|
24
|
+
const branch = spawnSync("git", ["symbolic-ref", "--quiet", "HEAD"], { cwd: repoRoot, encoding: "utf8", windowsHide: true });
|
|
25
|
+
if (branch.status !== 0) throw new Error("right-release requires a branch-attached primary Git checkout; detached HEAD is forbidden");
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
function pathspec(pattern, exclude = false) {
|
|
@@ -55,9 +57,25 @@ export function dirtyBuildInputs(repoRoot, inputs, baseline = "HEAD") {
|
|
|
55
57
|
if (!Array.isArray(inputs?.include) || inputs.include.length === 0) {
|
|
56
58
|
throw new Error("release config must declare non-empty buildInputs.include paths");
|
|
57
59
|
}
|
|
60
|
+
const required = new Set(inputs.required ?? []);
|
|
61
|
+
const files = [
|
|
62
|
+
...statusFiles(repoRoot, inputs.include, inputs.exclude),
|
|
63
|
+
...statusFiles(repoRoot, [...required], []),
|
|
64
|
+
];
|
|
65
|
+
return [...new Set(files)]
|
|
66
|
+
.filter((file) => required.has(file)
|
|
67
|
+
? fileChangedFromBaseline(repoRoot, file, baseline)
|
|
68
|
+
: inputs.json?.[file]
|
|
69
|
+
? jsonProjectionChanged(repoRoot, file, inputs.json[file], baseline)
|
|
70
|
+
: fileChangedFromBaseline(repoRoot, file, baseline))
|
|
71
|
+
.sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function statusFiles(repoRoot, include, exclude = []) {
|
|
75
|
+
if (!include.length) return [];
|
|
58
76
|
const specs = [
|
|
59
|
-
...
|
|
60
|
-
...
|
|
77
|
+
...include.map((pattern) => pathspec(pattern)),
|
|
78
|
+
...exclude.map((pattern) => pathspec(pattern, true)),
|
|
61
79
|
];
|
|
62
80
|
const output = git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...specs]);
|
|
63
81
|
const records = output.split("\0");
|
|
@@ -69,9 +87,5 @@ export function dirtyBuildInputs(repoRoot, inputs, baseline = "HEAD") {
|
|
|
69
87
|
files.push(record.slice(3).replaceAll("\\", "/"));
|
|
70
88
|
if (/[RC]/.test(status)) index += 1;
|
|
71
89
|
}
|
|
72
|
-
return
|
|
73
|
-
.filter((file) => inputs.json?.[file]
|
|
74
|
-
? jsonProjectionChanged(repoRoot, file, inputs.json[file], baseline)
|
|
75
|
-
: fileChangedFromBaseline(repoRoot, file, baseline))
|
|
76
|
-
.sort();
|
|
90
|
+
return files;
|
|
77
91
|
}
|
package/release-state.mjs
CHANGED
|
@@ -111,6 +111,8 @@ export async function runBuildStateMachine({
|
|
|
111
111
|
commit,
|
|
112
112
|
platform,
|
|
113
113
|
requiredInputs = [],
|
|
114
|
+
inputHashes,
|
|
115
|
+
cacheKey,
|
|
114
116
|
ops,
|
|
115
117
|
}) {
|
|
116
118
|
const releaseId = `${app}-${version}-${commit.slice(0, 8)}`;
|
|
@@ -121,6 +123,11 @@ export async function runBuildStateMachine({
|
|
|
121
123
|
if (sealed.manifest.app !== app || sealed.manifest.version !== version || sealed.manifest.commit !== commit) {
|
|
122
124
|
throw new Error(`sealed release identity mismatch: ${releaseId}`);
|
|
123
125
|
}
|
|
126
|
+
if (sealed.manifest.platform !== platform) throw new Error(`sealed release platform identity mismatch: ${releaseId}`);
|
|
127
|
+
if (inputHashes && JSON.stringify(sealed.manifest.inputs) !== JSON.stringify(inputHashes)) {
|
|
128
|
+
throw new Error(`sealed release input identity mismatch: ${releaseId}`);
|
|
129
|
+
}
|
|
130
|
+
if (cacheKey && sealed.manifest.cacheKey !== cacheKey) throw new Error(`sealed release cache identity mismatch: ${releaseId}`);
|
|
124
131
|
return { status: "sealed", resumed: true, releaseId, sealedDir };
|
|
125
132
|
}
|
|
126
133
|
await ops.preflight?.({ root, app, version, commit, platform, requiredInputs, releaseId });
|
package/release-state.test.mjs
CHANGED
|
@@ -198,6 +198,40 @@ test("a valid sealed release resumes without rebuilding", async () => {
|
|
|
198
198
|
assert.equal(builds, 0);
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
test("sealed build resume rejects changed input or cache identity", async () => {
|
|
202
|
+
const fx = fixture();
|
|
203
|
+
fx.manifest.inputs = { "src-tauri/Cargo.lock": "old-hash" };
|
|
204
|
+
fx.manifest.cacheKey = "old-cache";
|
|
205
|
+
writeFileSync(path.join(fx.sealedDir, "release-manifest.json"), `${JSON.stringify(fx.manifest, null, 2)}\n`);
|
|
206
|
+
|
|
207
|
+
await assert.rejects(
|
|
208
|
+
runBuildStateMachine({
|
|
209
|
+
root: fx.root,
|
|
210
|
+
app: fx.manifest.app,
|
|
211
|
+
version: fx.manifest.version,
|
|
212
|
+
commit: fx.manifest.commit,
|
|
213
|
+
platform: fx.manifest.platform,
|
|
214
|
+
inputHashes: { "src-tauri/Cargo.lock": "new-hash" },
|
|
215
|
+
cacheKey: fx.manifest.cacheKey,
|
|
216
|
+
ops: {},
|
|
217
|
+
}),
|
|
218
|
+
/sealed release input identity mismatch/i,
|
|
219
|
+
);
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
runBuildStateMachine({
|
|
222
|
+
root: fx.root,
|
|
223
|
+
app: fx.manifest.app,
|
|
224
|
+
version: fx.manifest.version,
|
|
225
|
+
commit: fx.manifest.commit,
|
|
226
|
+
platform: fx.manifest.platform,
|
|
227
|
+
inputHashes: fx.manifest.inputs,
|
|
228
|
+
cacheKey: "new-cache",
|
|
229
|
+
ops: {},
|
|
230
|
+
}),
|
|
231
|
+
/sealed release cache identity mismatch/i,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
201
235
|
test("manifest or file tampering blocks upload before any mutation", async () => {
|
|
202
236
|
const fx = fixture();
|
|
203
237
|
writeFileSync(fx.installer, "changed");
|
|
@@ -32,6 +32,33 @@ const apps = [
|
|
|
32
32
|
{ key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs"] },
|
|
33
33
|
];
|
|
34
34
|
|
|
35
|
+
function assertReleasePackageScripts(scripts, label) {
|
|
36
|
+
for (const platform of ["mac", "win"]) {
|
|
37
|
+
assert.equal(scripts[`release:build:${platform}`], `right-release build --platform ${platform}`, `${label} must use the tier-neutral ${platform} build entry point`);
|
|
38
|
+
for (const tier of ["patch", "update"]) {
|
|
39
|
+
assert.equal(scripts[`release:upload:${tier}:${platform}`], `right-release upload --platform ${platform} --tier ${tier}`, `${label} must select ${tier} only during ${platform} upload`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const genericTauri = scripts.tauri;
|
|
44
|
+
if (typeof genericTauri === "string") {
|
|
45
|
+
assert.doesNotMatch(
|
|
46
|
+
genericTauri.trim(),
|
|
47
|
+
/^(?:(?:pnpm|npm)\s+(?:(?:exec|dlx)\s+)?|npx\s+)?(?:(?:\.\/)?node_modules\/\.bin\/)?(?:@tauri-apps\/cli\s+)?tauri(?:\s|$)/,
|
|
48
|
+
`${label} generic \"tauri\" package script bypasses the RightKit release entry points; use an explicitly named dev, debug, unsigned, or local command instead`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertMacPackageEntry(packageCommand, scripts, label) {
|
|
54
|
+
assert.equal(packageCommand.cmd, "pnpm", `${label} must use the shared pnpm package entry point`);
|
|
55
|
+
assert.deepEqual(packageCommand.args, ["run", "rightkit:package:mac"], `${label} R2 release must use the dedicated signed and notarized macOS package entry point`);
|
|
56
|
+
const implementation = scripts["rightkit:package:mac"];
|
|
57
|
+
assert.equal(typeof implementation, "string", `${label} must define rightkit:package:mac`);
|
|
58
|
+
assert.doesNotMatch(implementation, /\bright-release\b/, `${label} rightkit:package:mac must package directly instead of recursing into the release build`);
|
|
59
|
+
assert.doesNotMatch(implementation, /\bpnpm(?:\s+run)?\s+rightkit:package:mac\b/, `${label} rightkit:package:mac must not recurse into itself`);
|
|
60
|
+
}
|
|
61
|
+
|
|
35
62
|
function resolveCargoVersionContract(versionManifest, canonicalVersions) {
|
|
36
63
|
const consumer = new Map(Object.entries(versionManifest.cargo ?? {}));
|
|
37
64
|
const staged = new Map(Object.entries(versionManifest.stagedCargo ?? {}));
|
|
@@ -314,10 +341,58 @@ test("Cargo version contract rejects staged versions that mismatch canonical man
|
|
|
314
341
|
);
|
|
315
342
|
});
|
|
316
343
|
|
|
344
|
+
test("release package scripts use tier-neutral builds and tiered uploads", () => {
|
|
345
|
+
const scripts = {
|
|
346
|
+
"release:build:mac": "right-release build --platform mac",
|
|
347
|
+
"release:build:win": "right-release build --platform win",
|
|
348
|
+
"release:upload:patch:mac": "right-release upload --platform mac --tier patch",
|
|
349
|
+
"release:upload:patch:win": "right-release upload --platform win --tier patch",
|
|
350
|
+
"release:upload:update:mac": "right-release upload --platform mac --tier update",
|
|
351
|
+
"release:upload:update:win": "right-release upload --platform win --tier update",
|
|
352
|
+
};
|
|
353
|
+
assert.doesNotThrow(() => assertReleasePackageScripts(scripts, "fixture"));
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("release package scripts reject a generic direct Tauri alias but allow named local commands", () => {
|
|
357
|
+
const scripts = {
|
|
358
|
+
"release:build:mac": "right-release build --platform mac",
|
|
359
|
+
"release:build:win": "right-release build --platform win",
|
|
360
|
+
"release:upload:patch:mac": "right-release upload --platform mac --tier patch",
|
|
361
|
+
"release:upload:patch:win": "right-release upload --platform win --tier patch",
|
|
362
|
+
"release:upload:update:mac": "right-release upload --platform mac --tier update",
|
|
363
|
+
"release:upload:update:win": "right-release upload --platform win --tier update",
|
|
364
|
+
"tauri:dev": "tauri dev",
|
|
365
|
+
"mac:build:debug": "tauri build --debug",
|
|
366
|
+
"build:win:unsigned": "tauri build --bundles nsis --no-sign",
|
|
367
|
+
"cargo:check": "cargo check --locked",
|
|
368
|
+
};
|
|
369
|
+
assert.doesNotThrow(() => assertReleasePackageScripts(scripts, "fixture"));
|
|
370
|
+
for (const genericTauri of ["tauri", "pnpm exec tauri", "pnpm dlx tauri", "./node_modules/.bin/tauri"]) {
|
|
371
|
+
assert.throws(
|
|
372
|
+
() => assertReleasePackageScripts({ ...scripts, tauri: genericTauri }, "fixture"),
|
|
373
|
+
/generic \"tauri\" package script bypasses/i,
|
|
374
|
+
genericTauri,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("mac release config uses a dedicated non-recursive package entry point", () => {
|
|
380
|
+
const command = { cmd: "pnpm", args: ["run", "rightkit:package:mac"] };
|
|
381
|
+
assert.doesNotThrow(() => assertMacPackageEntry(command, { "rightkit:package:mac": "node scripts/mac-dmg.mjs --notarize" }, "fixture"));
|
|
382
|
+
assert.throws(
|
|
383
|
+
() => assertMacPackageEntry(command, { "rightkit:package:mac": "right-release build --platform mac" }, "fixture"),
|
|
384
|
+
/instead of recursing into the release build/i,
|
|
385
|
+
);
|
|
386
|
+
assert.throws(
|
|
387
|
+
() => assertMacPackageEntry(command, { "rightkit:package:mac": "pnpm run rightkit:package:mac" }, "fixture"),
|
|
388
|
+
/must not recurse into itself/i,
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
317
392
|
test("RightKit exposes one current version manifest", () => {
|
|
318
393
|
assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
|
|
319
394
|
assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
|
|
320
|
-
assert.equal(versions.npm["@rightkit/release"], "0.2.
|
|
395
|
+
assert.equal(versions.npm["@rightkit/release"], "0.2.41");
|
|
321
396
|
assert.equal(versions.npm["@rightkit/legal"], "0.2.0");
|
|
322
397
|
assert.equal(versions.npm["@rightkit/license"], "0.1.5");
|
|
323
398
|
assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
|
|
@@ -399,14 +474,7 @@ for (const app of apps) {
|
|
|
399
474
|
assert.equal(pkg.scripts["release:doctor"], "right-release doctor");
|
|
400
475
|
assert.equal(pkg.scripts["release:mac"], undefined, "tierless release entry points are forbidden");
|
|
401
476
|
assert.equal(pkg.scripts["release:win"], undefined, "tierless release entry points are forbidden");
|
|
402
|
-
|
|
403
|
-
assert.equal(pkg.scripts["release:patch:win"], "right-release --platform win --tier patch");
|
|
404
|
-
assert.equal(pkg.scripts["release:update:mac"], "right-release --platform mac --tier update");
|
|
405
|
-
assert.equal(pkg.scripts["release:update:win"], "right-release --platform win --tier update");
|
|
406
|
-
assert.equal(pkg.scripts["publish:patch:mac"], "right-release publish --platform mac --tier patch");
|
|
407
|
-
assert.equal(pkg.scripts["publish:patch:win"], "right-release publish --platform win --tier patch");
|
|
408
|
-
assert.equal(pkg.scripts["publish:update:mac"], "right-release publish --platform mac --tier update");
|
|
409
|
-
assert.equal(pkg.scripts["publish:update:win"], "right-release publish --platform win --tier update");
|
|
477
|
+
assertReleasePackageScripts(pkg.scripts, app.key);
|
|
410
478
|
assert.equal(pkg.scripts["deps:check"], "right-release deps --check");
|
|
411
479
|
assert.equal(pkg.scripts["deps:update"], "right-release deps --update");
|
|
412
480
|
assert.ok(!Object.entries(pkg.scripts).some(([name, command]) => /^(?:release|publish):/i.test(name) && /unsigned|--no-sign/i.test(command)), `${app.key} release/publish commands must not expose an unsigned macOS DMG mode`);
|
|
@@ -431,8 +499,7 @@ for (const app of apps) {
|
|
|
431
499
|
assert.match(updater.key, /\/updates\/(mac|windows)\/current\//, `${app.key} ${platform} updaters must replace the stable current R2 object`);
|
|
432
500
|
}
|
|
433
501
|
}
|
|
434
|
-
|
|
435
|
-
assert.deepEqual(config.targets.mac.package.args, ["run", "mac:dmg:notarized"], `${app.key} R2 release must use the signed and notarized macOS package entry point`);
|
|
502
|
+
assertMacPackageEntry(config.targets.mac.package, pkg.scripts, app.key);
|
|
436
503
|
for (const installer of config.targets.mac.installer.artifacts) {
|
|
437
504
|
assert.equal(path.dirname(installer.file), ".", `${app.key} macOS installer must be copied to the app package root before upload`);
|
|
438
505
|
}
|
package/rightkit-versions.json
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, realpathSync, symlinkSync, unlinkSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function createTargetBridge({
|
|
5
|
+
link,
|
|
6
|
+
target,
|
|
7
|
+
platform = process.platform,
|
|
8
|
+
lstat = lstatSync,
|
|
9
|
+
mkdir = mkdirSync,
|
|
10
|
+
realpath = realpathSync,
|
|
11
|
+
symlink = symlinkSync,
|
|
12
|
+
unlink = unlinkSync,
|
|
13
|
+
}) {
|
|
14
|
+
let acquired = false;
|
|
15
|
+
let created = false;
|
|
16
|
+
let ownership = null;
|
|
17
|
+
|
|
18
|
+
const bridge = {
|
|
19
|
+
ensure() {
|
|
20
|
+
mkdir(target, { recursive: true });
|
|
21
|
+
let entry = readEntry(link, lstat);
|
|
22
|
+
if (!entry) {
|
|
23
|
+
symlink(target, link, platform === "win32" ? "junction" : "dir");
|
|
24
|
+
entry = lstat(link);
|
|
25
|
+
if (!entry.isSymbolicLink()) throw new Error(`RightKit target bridge was not created as a symbolic link: ${link}`);
|
|
26
|
+
if (!sameRealPath(link, target, realpath, platform)) {
|
|
27
|
+
throw new Error(`RightKit target bridge is not the owned shared cache target after creation: ${link}`);
|
|
28
|
+
}
|
|
29
|
+
ownership = entryIdentity(entry);
|
|
30
|
+
created = true;
|
|
31
|
+
} else {
|
|
32
|
+
if (!entry.isSymbolicLink()) throw new Error(`primary checkout target is not a symbolic link; refusing to replace it: ${link}`);
|
|
33
|
+
if (!sameRealPath(link, target, realpath, platform)) {
|
|
34
|
+
throw new Error(`primary checkout target is not the owned shared cache target; refusing to replace it: ${link}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
acquired = true;
|
|
38
|
+
return { created, link, target };
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
release() {
|
|
42
|
+
if (!acquired) return false;
|
|
43
|
+
if (!ownership) {
|
|
44
|
+
acquired = false;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const entry = readEntry(link, lstat);
|
|
48
|
+
if (!entry) {
|
|
49
|
+
acquired = false;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (!sameEntry(entry, ownership)) {
|
|
53
|
+
acquired = false;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
let exactOwnedTarget = false;
|
|
57
|
+
try {
|
|
58
|
+
exactOwnedTarget = sameRealPath(link, target, realpath, platform);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.code !== "ENOENT") throw error;
|
|
61
|
+
}
|
|
62
|
+
if (!exactOwnedTarget) {
|
|
63
|
+
acquired = false;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
unlink(link);
|
|
67
|
+
acquired = false;
|
|
68
|
+
ownership = null;
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async run(operation) {
|
|
73
|
+
let operationError;
|
|
74
|
+
try {
|
|
75
|
+
return await operation(bridge);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
operationError = error;
|
|
78
|
+
throw error;
|
|
79
|
+
} finally {
|
|
80
|
+
try {
|
|
81
|
+
bridge.release();
|
|
82
|
+
} catch (cleanupError) {
|
|
83
|
+
if (!operationError) throw cleanupError;
|
|
84
|
+
operationError.cleanupError = cleanupError;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return bridge;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function entryIdentity(stats) {
|
|
94
|
+
return { dev: stats.dev, ino: stats.ino, symbolicLink: stats.isSymbolicLink() };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sameEntry(stats, expected) {
|
|
98
|
+
return stats.dev === expected.dev
|
|
99
|
+
&& stats.ino === expected.ino
|
|
100
|
+
&& stats.isSymbolicLink() === expected.symbolicLink
|
|
101
|
+
&& expected.symbolicLink;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readEntry(file, lstat) {
|
|
105
|
+
try {
|
|
106
|
+
return lstat(file);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.code === "ENOENT") return null;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sameRealPath(left, right, realpath, platform) {
|
|
114
|
+
const canonical = (value) => {
|
|
115
|
+
const resolved = path.normalize(realpath(value));
|
|
116
|
+
return platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
117
|
+
};
|
|
118
|
+
return canonical(left) === canonical(right);
|
|
119
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import { createTargetBridge } from "./target-bridge.mjs";
|
|
8
|
+
|
|
9
|
+
function fixture() {
|
|
10
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightkit-target-bridge-"));
|
|
11
|
+
const target = path.join(root, "shared", "target");
|
|
12
|
+
const link = path.join(root, "app", "src-tauri", "target");
|
|
13
|
+
mkdirSync(target, { recursive: true });
|
|
14
|
+
mkdirSync(path.dirname(link), { recursive: true });
|
|
15
|
+
writeFileSync(path.join(target, "keep.txt"), "shared-cache\n");
|
|
16
|
+
return { root, target, link };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("removes an invocation-created bridge after success without touching the shared target", async () => {
|
|
20
|
+
const fx = fixture();
|
|
21
|
+
const bridge = createTargetBridge(fx);
|
|
22
|
+
await bridge.run(async () => {
|
|
23
|
+
bridge.ensure();
|
|
24
|
+
assert.equal(realpathSync(fx.link), realpathSync(fx.target));
|
|
25
|
+
});
|
|
26
|
+
assert.throws(() => lstatSync(fx.link), { code: "ENOENT" });
|
|
27
|
+
assert.equal(readFileSync(path.join(fx.target, "keep.txt"), "utf8"), "shared-cache\n");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("removes an invocation-created bridge after the build throws", async () => {
|
|
31
|
+
const fx = fixture();
|
|
32
|
+
const bridge = createTargetBridge(fx);
|
|
33
|
+
await assert.rejects(
|
|
34
|
+
bridge.run(async () => {
|
|
35
|
+
bridge.ensure();
|
|
36
|
+
throw new Error("package failed");
|
|
37
|
+
}),
|
|
38
|
+
/package failed/,
|
|
39
|
+
);
|
|
40
|
+
assert.throws(() => lstatSync(fx.link), { code: "ENOENT" });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("removes an invocation-created bridge after a signal-style abort", async () => {
|
|
44
|
+
const fx = fixture();
|
|
45
|
+
const bridge = createTargetBridge(fx);
|
|
46
|
+
await assert.rejects(
|
|
47
|
+
bridge.run(async () => {
|
|
48
|
+
bridge.ensure();
|
|
49
|
+
throw new Error("release interrupted by SIGTERM");
|
|
50
|
+
}),
|
|
51
|
+
/SIGTERM/,
|
|
52
|
+
);
|
|
53
|
+
assert.throws(() => lstatSync(fx.link), { code: "ENOENT" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("accepts but preserves a pre-existing exact shared-target bridge", async () => {
|
|
57
|
+
const fx = fixture();
|
|
58
|
+
symlinkSync(fx.target, fx.link, process.platform === "win32" ? "junction" : "dir");
|
|
59
|
+
await createTargetBridge(fx).run(async (bridge) => {
|
|
60
|
+
assert.deepEqual(bridge.ensure(), { created: false, link: fx.link, target: fx.target });
|
|
61
|
+
});
|
|
62
|
+
assert.equal(realpathSync(fx.link), realpathSync(fx.target));
|
|
63
|
+
assert.equal(readFileSync(path.join(fx.target, "keep.txt"), "utf8"), "shared-cache\n");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("fails closed when the new bridge is replaced immediately after symlink creation", () => {
|
|
67
|
+
const fx = fixture();
|
|
68
|
+
const other = path.join(fx.root, "racing-target");
|
|
69
|
+
mkdirSync(other);
|
|
70
|
+
const bridge = createTargetBridge({
|
|
71
|
+
...fx,
|
|
72
|
+
symlink(target, link, type) {
|
|
73
|
+
symlinkSync(target, link, type);
|
|
74
|
+
unlinkSync(link);
|
|
75
|
+
symlinkSync(other, link, type);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
assert.throws(() => bridge.ensure(), /owned shared cache target/i);
|
|
79
|
+
assert.equal(realpathSync(fx.link), realpathSync(other));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("fails closed and preserves an unrelated symlink", async () => {
|
|
83
|
+
const fx = fixture();
|
|
84
|
+
const other = path.join(fx.root, "other-target");
|
|
85
|
+
mkdirSync(other);
|
|
86
|
+
symlinkSync(other, fx.link, process.platform === "win32" ? "junction" : "dir");
|
|
87
|
+
await assert.rejects(
|
|
88
|
+
createTargetBridge(fx).run(async (bridge) => bridge.ensure()),
|
|
89
|
+
/not the owned shared cache target/i,
|
|
90
|
+
);
|
|
91
|
+
assert.equal(realpathSync(fx.link), realpathSync(other));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("preserves a same-target symlink that replaces an acquired bridge", () => {
|
|
95
|
+
const fx = fixture();
|
|
96
|
+
let successfulStats = 0;
|
|
97
|
+
const bridge = createTargetBridge({
|
|
98
|
+
...fx,
|
|
99
|
+
lstat(file) {
|
|
100
|
+
const stats = lstatSync(file);
|
|
101
|
+
successfulStats += 1;
|
|
102
|
+
if (successfulStats === 1) return stats;
|
|
103
|
+
return { dev: stats.dev, ino: stats.ino + 1, isSymbolicLink: () => stats.isSymbolicLink() };
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
bridge.ensure();
|
|
107
|
+
unlinkSync(fx.link);
|
|
108
|
+
symlinkSync(fx.target, fx.link, process.platform === "win32" ? "junction" : "dir");
|
|
109
|
+
assert.equal(bridge.release(), false);
|
|
110
|
+
assert.equal(realpathSync(fx.link), realpathSync(fx.target));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("preserves a different-target symlink that replaces an acquired bridge", () => {
|
|
114
|
+
const fx = fixture();
|
|
115
|
+
const other = path.join(fx.root, "replacement-target");
|
|
116
|
+
mkdirSync(other);
|
|
117
|
+
const bridge = createTargetBridge(fx);
|
|
118
|
+
bridge.ensure();
|
|
119
|
+
unlinkSync(fx.link);
|
|
120
|
+
symlinkSync(other, fx.link, process.platform === "win32" ? "junction" : "dir");
|
|
121
|
+
assert.equal(bridge.release(), false);
|
|
122
|
+
assert.equal(realpathSync(fx.link), realpathSync(other));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("fails closed and preserves a real target directory with user content", async () => {
|
|
126
|
+
const fx = fixture();
|
|
127
|
+
mkdirSync(fx.link);
|
|
128
|
+
writeFileSync(path.join(fx.link, "user.txt"), "keep\n");
|
|
129
|
+
await assert.rejects(
|
|
130
|
+
createTargetBridge(fx).run(async (bridge) => bridge.ensure()),
|
|
131
|
+
/not a symbolic link/i,
|
|
132
|
+
);
|
|
133
|
+
assert.equal(readFileSync(path.join(fx.link, "user.txt"), "utf8"), "keep\n");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("cleanup is idempotent and reports process errors without masking a build failure", async () => {
|
|
137
|
+
const fx = fixture();
|
|
138
|
+
const bridge = createTargetBridge(fx);
|
|
139
|
+
bridge.ensure();
|
|
140
|
+
assert.equal(bridge.release(), true);
|
|
141
|
+
assert.equal(bridge.release(), false);
|
|
142
|
+
|
|
143
|
+
const cleanupFailure = new Error("unlink denied");
|
|
144
|
+
const failing = createTargetBridge({ ...fx, unlink: () => { throw cleanupFailure; } });
|
|
145
|
+
const buildFailure = new Error("build aborted");
|
|
146
|
+
await assert.rejects(
|
|
147
|
+
failing.run(async () => {
|
|
148
|
+
failing.ensure();
|
|
149
|
+
throw buildFailure;
|
|
150
|
+
}),
|
|
151
|
+
(error) => error === buildFailure && error.cleanupError === cleanupFailure,
|
|
152
|
+
);
|
|
153
|
+
assert.equal(lstatSync(fx.link).isSymbolicLink(), true);
|
|
154
|
+
unlinkSync(fx.link);
|
|
155
|
+
});
|