@rightkit/release 0.2.40 → 0.2.42
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-release.mjs +10 -42
- package/build-release.test.mjs +6 -0
- package/package.json +1 -1
- package/release-invocation.mjs +48 -0
- package/release.mjs +33 -2
- package/release.test.mjs +96 -3
- package/right-suite-contract.test.mjs +26 -3
- package/rightkit-versions.json +2 -2
- package/target-bridge.mjs +119 -0
- package/target-bridge.test.mjs +155 -0
package/build-release.mjs
CHANGED
|
@@ -20,9 +20,10 @@ 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 { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
|
|
23
|
+
import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } 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");
|
|
@@ -79,8 +80,8 @@ try {
|
|
|
79
80
|
}
|
|
80
81
|
const target = config.targets?.[platform];
|
|
81
82
|
if (!target?.package) fail(`${config.app} has no ${platform} package command`);
|
|
82
|
-
const requiredInputs =
|
|
83
|
-
const dirtyInputs = dirtyBuildInputs(repoRoot,
|
|
83
|
+
const { requiredInputs, buildInputs } = resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target });
|
|
84
|
+
const dirtyInputs = dirtyBuildInputs(repoRoot, buildInputs, commit);
|
|
84
85
|
if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
|
|
85
86
|
const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
|
|
86
87
|
const inputHashes = hashInputs(requiredInputs);
|
|
@@ -125,8 +126,10 @@ try {
|
|
|
125
126
|
tools: toolVersions,
|
|
126
127
|
createdAt: new Date().toISOString(),
|
|
127
128
|
};
|
|
129
|
+
const targetLink = path.join(appRoot, "src-tauri", "target");
|
|
130
|
+
const targetBridge = createTargetBridge({ link: targetLink, target: env.CARGO_TARGET_DIR });
|
|
128
131
|
|
|
129
|
-
const result = await runBuildStateMachine({
|
|
132
|
+
const result = await targetBridge.run(async () => runBuildStateMachine({
|
|
130
133
|
root: repoRoot,
|
|
131
134
|
app: config.app,
|
|
132
135
|
version: config.version,
|
|
@@ -158,9 +161,9 @@ try {
|
|
|
158
161
|
prepare: async () => {
|
|
159
162
|
throwIfInterrupted();
|
|
160
163
|
const cacheTarget = env.CARGO_TARGET_DIR;
|
|
161
|
-
const targetLink = path.join(appRoot, "src-tauri", "target");
|
|
162
164
|
mkdirSync(cacheTarget, { recursive: true });
|
|
163
|
-
if (
|
|
165
|
+
if (cacheMode === "shared") targetBridge.ensure();
|
|
166
|
+
else if (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
|
|
164
167
|
else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
|
|
165
168
|
fail(`primary checkout target exists and is not the release cache link: ${targetLink}`);
|
|
166
169
|
}
|
|
@@ -186,7 +189,7 @@ try {
|
|
|
186
189
|
checkpoint(stateRoot, "sealed");
|
|
187
190
|
},
|
|
188
191
|
},
|
|
189
|
-
});
|
|
192
|
+
}));
|
|
190
193
|
if (cacheMode === "shared") {
|
|
191
194
|
const snapshot = inspectCache({ cacheRoot: sharedCacheRoot });
|
|
192
195
|
const plan = planCachePrune({ snapshot, policy: readCachePolicy(process.env), protectedEntryIds: new Set([sharedLayout.id]) });
|
|
@@ -200,37 +203,6 @@ try {
|
|
|
200
203
|
lock.release();
|
|
201
204
|
}
|
|
202
205
|
|
|
203
|
-
function inputPaths(appRoot, configPath, target) {
|
|
204
|
-
const candidates = [
|
|
205
|
-
configPath,
|
|
206
|
-
path.join(appRoot, "package.json"),
|
|
207
|
-
path.join(appRoot, "pnpm-lock.yaml"),
|
|
208
|
-
path.join(appRoot, "src-tauri", "Cargo.toml"),
|
|
209
|
-
path.join(appRoot, "src-tauri", "Cargo.lock"),
|
|
210
|
-
...(target.preflight?.files ?? []).map((file) => path.resolve(appRoot, expandEnv(file))),
|
|
211
|
-
];
|
|
212
|
-
const required = [...new Set(candidates)];
|
|
213
|
-
for (const file of required) if (!existsSync(file)) fail(`missing required release input: ${file}`);
|
|
214
|
-
return required;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function repoBuildInputs(repoRoot, appRoot, configured, requiredInputs) {
|
|
218
|
-
if (!Array.isArray(configured?.include) || configured.include.length === 0) {
|
|
219
|
-
fail("release config must declare non-empty buildInputs.include paths");
|
|
220
|
-
}
|
|
221
|
-
const appPrefix = path.relative(repoRoot, appRoot).replaceAll("\\", "/");
|
|
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));
|
|
226
|
-
return {
|
|
227
|
-
include: configured.include.map(qualify),
|
|
228
|
-
exclude: (configured.exclude ?? []).map(qualify),
|
|
229
|
-
required,
|
|
230
|
-
json: Object.fromEntries(Object.entries(configured.json ?? {}).map(([file, fields]) => [qualify(file), fields])),
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
206
|
function hashInputs(files) {
|
|
235
207
|
return Object.fromEntries(files.map((file) => [path.relative(path.dirname(path.dirname(file)), file), hashFile(file)]));
|
|
236
208
|
}
|
|
@@ -433,10 +405,6 @@ function killTree(pid) {
|
|
|
433
405
|
else { try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } } }
|
|
434
406
|
}
|
|
435
407
|
|
|
436
|
-
function expandEnv(value) {
|
|
437
|
-
return String(value).replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "").replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? "");
|
|
438
|
-
}
|
|
439
|
-
|
|
440
408
|
function writeJson(file, value) {
|
|
441
409
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
442
410
|
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
package/build-release.test.mjs
CHANGED
|
@@ -42,3 +42,9 @@ test("dirty release config is rejected before the config module is imported", ()
|
|
|
42
42
|
assert.ok(guard >= 0, "config cleanliness guard must exist");
|
|
43
43
|
assert.ok(guard < imported, "config cleanliness guard must run before module import");
|
|
44
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.42",
|
|
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
|
@@ -25,6 +25,48 @@ export function assertPrimaryReleaseCheckout(repoRoot) {
|
|
|
25
25
|
if (branch.status !== 0) throw new Error("right-release requires a branch-attached primary Git checkout; detached HEAD is forbidden");
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export function resolveConfiguredBuildInputs(config, target, label = "release config") {
|
|
29
|
+
const configured = target?.buildInputs ?? config?.buildInputs;
|
|
30
|
+
if (!Array.isArray(configured?.include) || configured.include.length === 0) {
|
|
31
|
+
throw new Error(`${label} must declare non-empty buildInputs.include paths`);
|
|
32
|
+
}
|
|
33
|
+
return configured;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target, env = process.env }) {
|
|
37
|
+
const requiredInputs = [
|
|
38
|
+
configPath,
|
|
39
|
+
path.join(appRoot, "package.json"),
|
|
40
|
+
path.join(appRoot, "pnpm-lock.yaml"),
|
|
41
|
+
path.join(appRoot, "src-tauri", "Cargo.toml"),
|
|
42
|
+
path.join(appRoot, "src-tauri", "Cargo.lock"),
|
|
43
|
+
...(target?.preflight?.files ?? []).map((file) => path.resolve(appRoot, expandEnv(file, env))),
|
|
44
|
+
].filter((file, index, files) => files.indexOf(file) === index);
|
|
45
|
+
for (const file of requiredInputs) {
|
|
46
|
+
if (!existsSync(file)) throw new Error(`missing required release input: ${file}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const configured = resolveConfiguredBuildInputs(config, target);
|
|
50
|
+
const appPrefix = path.relative(repoRoot, appRoot).replaceAll("\\", "/");
|
|
51
|
+
const qualify = (pattern) => path.posix.normalize([
|
|
52
|
+
appPrefix,
|
|
53
|
+
pattern.replaceAll("\\", "/").replace(/^\.\//, ""),
|
|
54
|
+
].filter(Boolean).join("/"));
|
|
55
|
+
const required = requiredInputs
|
|
56
|
+
.map((file) => path.relative(repoRoot, file).replaceAll("\\", "/"))
|
|
57
|
+
.filter((file) => file && file !== ".." && !file.startsWith("../") && !path.isAbsolute(file));
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
requiredInputs,
|
|
61
|
+
buildInputs: {
|
|
62
|
+
include: configured.include.map(qualify),
|
|
63
|
+
exclude: (configured.exclude ?? []).map(qualify),
|
|
64
|
+
required,
|
|
65
|
+
json: Object.fromEntries(Object.entries(configured.json ?? {}).map(([file, fields]) => [qualify(file), fields])),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
28
70
|
function pathspec(pattern, exclude = false) {
|
|
29
71
|
const normalized = pattern.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
30
72
|
return `:(top,${exclude ? "exclude," : ""}glob)${normalized}`;
|
|
@@ -89,3 +131,9 @@ function statusFiles(repoRoot, include, exclude = []) {
|
|
|
89
131
|
}
|
|
90
132
|
return files;
|
|
91
133
|
}
|
|
134
|
+
|
|
135
|
+
function expandEnv(value, env) {
|
|
136
|
+
return String(value)
|
|
137
|
+
.replace(/%([^%]+)%/g, (_, name) => env[name] ?? "")
|
|
138
|
+
.replace(/\$\{([^}]+)\}/g, (_, name) => env[name] ?? "");
|
|
139
|
+
}
|
package/release.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { access, readFile, readdir } from "node:fs/promises";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import { validateRightKitCargoContract } from "./cargo-contract.mjs";
|
|
8
8
|
import { assertQaBackdoorContract } from "./qa-contract.mjs";
|
|
9
9
|
import { assertLegalReleaseContract } from "./legal-contract.mjs";
|
|
10
|
+
import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
|
|
10
11
|
|
|
11
12
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
|
|
@@ -63,7 +64,20 @@ if (opts.upload) fail("combined build+upload was removed; run right-release buil
|
|
|
63
64
|
if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
|
|
64
65
|
const sccacheVersion = assertSharedSccachePrerequisite();
|
|
65
66
|
|
|
66
|
-
const
|
|
67
|
+
const requestedConfigPath = path.resolve(opts.config);
|
|
68
|
+
const configPath = opts.doctor ? realpathSync(requestedConfigPath) : requestedConfigPath;
|
|
69
|
+
let doctorInvocation;
|
|
70
|
+
if (opts.doctor) {
|
|
71
|
+
const invocationRoot = path.dirname(configPath);
|
|
72
|
+
const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
|
|
73
|
+
assertPrimaryReleaseCheckout(repoRoot);
|
|
74
|
+
const relativeConfig = path.relative(repoRoot, configPath).replaceAll("\\", "/");
|
|
75
|
+
if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
|
|
76
|
+
const commit = git(repoRoot, ["rev-parse", "HEAD"]);
|
|
77
|
+
const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
|
|
78
|
+
if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
|
|
79
|
+
doctorInvocation = { repoRoot, commit };
|
|
80
|
+
}
|
|
67
81
|
const config = (await import(pathToFileURL(configPath))).default;
|
|
68
82
|
if (!config) usage(2, `config did not export default: ${configPath}`);
|
|
69
83
|
if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<missing>"} (expected 1)`);
|
|
@@ -85,6 +99,17 @@ if (opts.platform === "win" && !target.sign?.files?.length) {
|
|
|
85
99
|
}
|
|
86
100
|
if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
|
|
87
101
|
if (opts.doctor) {
|
|
102
|
+
const { buildInputs } = resolveReleaseBuildInputs({
|
|
103
|
+
repoRoot: doctorInvocation.repoRoot,
|
|
104
|
+
appRoot: root,
|
|
105
|
+
configPath,
|
|
106
|
+
config,
|
|
107
|
+
target,
|
|
108
|
+
});
|
|
109
|
+
const dirtyInputs = dirtyBuildInputs(doctorInvocation.repoRoot, buildInputs, doctorInvocation.commit);
|
|
110
|
+
if (dirtyInputs.length > 0) {
|
|
111
|
+
fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
|
|
112
|
+
}
|
|
88
113
|
console.log(`right-release ${VERSION}`);
|
|
89
114
|
console.log(`config: ${configPath}`);
|
|
90
115
|
console.log(`app: ${config.app}`);
|
|
@@ -178,6 +203,12 @@ function fail(message) {
|
|
|
178
203
|
process.exit(1);
|
|
179
204
|
}
|
|
180
205
|
|
|
206
|
+
function git(cwd, runArgs) {
|
|
207
|
+
const result = spawnSync("git", runArgs, { cwd, encoding: "utf8", windowsHide: true });
|
|
208
|
+
if (result.status !== 0) fail(`git ${runArgs.join(" ")} failed: ${(result.stderr ?? result.stdout ?? "").trim()}`);
|
|
209
|
+
return result.stdout.trim();
|
|
210
|
+
}
|
|
211
|
+
|
|
181
212
|
function assertSharedSccachePrerequisite() {
|
|
182
213
|
if (process.env.RIGHT_RELEASE_CACHE_MODE !== "shared") return null;
|
|
183
214
|
const result = spawnSync("sccache", ["--version"], { encoding: "utf8", windowsHide: true });
|
package/release.test.mjs
CHANGED
|
@@ -2,14 +2,20 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
|
|
9
9
|
const release = fileURLToPath(new URL("./release.mjs", import.meta.url));
|
|
10
|
+
const cli = fileURLToPath(new URL("./cli/right-release.mjs", import.meta.url));
|
|
10
11
|
const versions = JSON.parse(readFileSync(new URL("./rightkit-versions.json", import.meta.url), "utf8"));
|
|
12
|
+
const defaultBuildInputs = { include: ["package.json", "pnpm-lock.yaml", "src-tauri/**"] };
|
|
11
13
|
|
|
12
|
-
function
|
|
14
|
+
function git(cwd, ...args) {
|
|
15
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fixture({ signed = true, publish = false, packageJson, cargoManifest, cargoConfig, repoCargoConfig, siblingCargoManifest, buildInputs = defaultBuildInputs } = {}) {
|
|
13
19
|
const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), "right-release-test-"));
|
|
14
20
|
const dir = repoCargoConfig ? path.join(fixtureRoot, "apps", "fixture") : fixtureRoot;
|
|
15
21
|
mkdirSync(dir, { recursive: true });
|
|
@@ -40,6 +46,7 @@ function fixture({ signed = true, publish = false, packageJson, cargoManifest, c
|
|
|
40
46
|
checks: [],
|
|
41
47
|
targets: {
|
|
42
48
|
win: {
|
|
49
|
+
...(buildInputs ? { buildInputs } : {}),
|
|
43
50
|
signed,
|
|
44
51
|
package: { cmd: "node", args: ["-e", "process.exit(0)"] },
|
|
45
52
|
...(publish ? { publish: { cmd: "node", args: ["publish-update.mjs"] } } : {}),
|
|
@@ -51,6 +58,12 @@ function fixture({ signed = true, publish = false, packageJson, cargoManifest, c
|
|
|
51
58
|
},
|
|
52
59
|
})};\n`,
|
|
53
60
|
);
|
|
61
|
+
writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8");
|
|
62
|
+
const tauriDir = path.join(dir, "src-tauri");
|
|
63
|
+
mkdirSync(tauriDir, { recursive: true });
|
|
64
|
+
writeFileSync(path.join(tauriDir, "Cargo.toml"), `[package]\nname = "fixture-tauri"\nversion = "0.0.0"\nedition = "2021"\n[lib]\npath = "lib.rs"\n`, "utf8");
|
|
65
|
+
writeFileSync(path.join(tauriDir, "Cargo.lock"), "# fixture lock\n", "utf8");
|
|
66
|
+
writeFileSync(path.join(tauriDir, "lib.rs"), "pub fn fixture() {}\n", "utf8");
|
|
54
67
|
if (cargoManifest) {
|
|
55
68
|
writeFileSync(path.join(dir, "Cargo.toml"), `${cargoManifest}\n[lib]\npath = "fixture.rs"\n`, "utf8");
|
|
56
69
|
writeFileSync(path.join(dir, "fixture.rs"), "", "utf8");
|
|
@@ -61,7 +74,6 @@ function fixture({ signed = true, publish = false, packageJson, cargoManifest, c
|
|
|
61
74
|
writeFileSync(path.join(cargoDir, "config.toml"), cargoConfig, "utf8");
|
|
62
75
|
}
|
|
63
76
|
if (repoCargoConfig) {
|
|
64
|
-
mkdirSync(path.join(fixtureRoot, ".git"), { recursive: true });
|
|
65
77
|
const cargoDir = path.join(fixtureRoot, ".cargo");
|
|
66
78
|
mkdirSync(cargoDir, { recursive: true });
|
|
67
79
|
writeFileSync(path.join(cargoDir, "config.toml"), repoCargoConfig, "utf8");
|
|
@@ -71,6 +83,11 @@ function fixture({ signed = true, publish = false, packageJson, cargoManifest, c
|
|
|
71
83
|
mkdirSync(sibling, { recursive: true });
|
|
72
84
|
writeFileSync(path.join(sibling, "Cargo.toml"), siblingCargoManifest, "utf8");
|
|
73
85
|
}
|
|
86
|
+
git(fixtureRoot, "init", "--initial-branch", "main");
|
|
87
|
+
git(fixtureRoot, "config", "user.email", "release-test@example.com");
|
|
88
|
+
git(fixtureRoot, "config", "user.name", "Release Test");
|
|
89
|
+
git(fixtureRoot, "add", ".");
|
|
90
|
+
git(fixtureRoot, "commit", "-m", "initial");
|
|
74
91
|
return config;
|
|
75
92
|
}
|
|
76
93
|
|
|
@@ -128,6 +145,12 @@ function runRaw(config, ...args) {
|
|
|
128
145
|
});
|
|
129
146
|
}
|
|
130
147
|
|
|
148
|
+
function runDoctor(config, ...args) {
|
|
149
|
+
return spawnSync(process.execPath, [cli, "doctor", "--config", config, "--platform", "win", ...args], {
|
|
150
|
+
encoding: "utf8",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
test("accepts a tier-neutral internal build", () => {
|
|
132
155
|
const result = run(fixture());
|
|
133
156
|
assert.equal(result.status, 0, result.stderr);
|
|
@@ -204,6 +227,76 @@ test("doctor accepts an exact crates.io RightKit pin with benign Cargo config",
|
|
|
204
227
|
assert.match(result.stdout, /right-release/);
|
|
205
228
|
});
|
|
206
229
|
|
|
230
|
+
test("doctor rejects a selected target with missing or empty buildInputs.include", () => {
|
|
231
|
+
for (const buildInputs of [null, { include: [] }]) {
|
|
232
|
+
const result = run(fixture({ buildInputs }), "--doctor");
|
|
233
|
+
assert.notEqual(result.status, 0);
|
|
234
|
+
assert.match(result.stderr, /non-empty buildInputs\.include paths/i);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("doctor passes a clean selected target", () => {
|
|
239
|
+
const result = runDoctor(fixture());
|
|
240
|
+
assert.equal(result.status, 0, result.stderr);
|
|
241
|
+
assert.match(result.stdout, /right-release 0\.2\.42/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("doctor permits unrelated dirt", () => {
|
|
245
|
+
const config = fixture();
|
|
246
|
+
const root = path.dirname(config);
|
|
247
|
+
mkdirSync(path.join(root, "notes"));
|
|
248
|
+
writeFileSync(path.join(root, "notes", "scratch.md"), "unrelated\n");
|
|
249
|
+
const result = run(config, "--doctor");
|
|
250
|
+
assert.equal(result.status, 0, result.stderr);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("doctor rejects tracked dirt inside declared buildInputs with the exact path", () => {
|
|
254
|
+
const config = fixture();
|
|
255
|
+
writeFileSync(path.join(path.dirname(config), "src-tauri", "lib.rs"), "pub fn changed() {}\n");
|
|
256
|
+
const result = run(config, "--doctor");
|
|
257
|
+
assert.notEqual(result.status, 0);
|
|
258
|
+
assert.match(result.stderr, /dirty files can change the packaged app[\s\S]*- src-tauri\/lib\.rs/i);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("doctor rejects untracked dirt inside declared buildInputs with the exact path", () => {
|
|
262
|
+
const config = fixture();
|
|
263
|
+
writeFileSync(path.join(path.dirname(config), "src-tauri", "new-runtime.rs"), "pub fn new_runtime() {}\n");
|
|
264
|
+
const result = run(config, "--doctor");
|
|
265
|
+
assert.notEqual(result.status, 0);
|
|
266
|
+
assert.match(result.stderr, /dirty files can change the packaged app[\s\S]*- src-tauri\/new-runtime\.rs/i);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("doctor rejects dirty required release metadata even when it is not declared", () => {
|
|
270
|
+
const config = fixture({ buildInputs: { include: ["src-tauri/lib.rs"] } });
|
|
271
|
+
writeFileSync(path.join(path.dirname(config), "pnpm-lock.yaml"), "lockfileVersion: '9.1'\n");
|
|
272
|
+
const result = run(config, "--doctor");
|
|
273
|
+
assert.notEqual(result.status, 0);
|
|
274
|
+
assert.match(result.stderr, /dirty files can change the packaged app[\s\S]*- pnpm-lock\.yaml/i);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("doctor rejects a dirty release config before importing it", () => {
|
|
278
|
+
const config = fixture();
|
|
279
|
+
writeFileSync(config, `${readFileSync(config, "utf8")}\n// dirty config\n`);
|
|
280
|
+
const result = run(config, "--doctor");
|
|
281
|
+
assert.notEqual(result.status, 0);
|
|
282
|
+
assert.match(result.stderr, /dirty release config cannot be executed[\s\S]*- right-release\.config\.mjs/i);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("doctor rejects linked and detached checkouts like build runtime", () => {
|
|
286
|
+
const primaryConfig = fixture();
|
|
287
|
+
const primary = path.dirname(primaryConfig);
|
|
288
|
+
const linked = `${primary}-linked`;
|
|
289
|
+
git(primary, "worktree", "add", "--detach", linked);
|
|
290
|
+
const linkedResult = run(path.join(linked, "right-release.config.mjs"), "--doctor");
|
|
291
|
+
assert.notEqual(linkedResult.status, 0);
|
|
292
|
+
assert.match(linkedResult.stderr, /must be invoked from the primary Git worktree/i);
|
|
293
|
+
|
|
294
|
+
git(primary, "checkout", "--detach");
|
|
295
|
+
const detachedResult = run(primaryConfig, "--doctor");
|
|
296
|
+
assert.notEqual(detachedResult.status, 0);
|
|
297
|
+
assert.match(detachedResult.stderr, /detached HEAD is forbidden/i);
|
|
298
|
+
});
|
|
299
|
+
|
|
207
300
|
test("doctor scopes Cargo inspection to the configured app inside a monorepo", () => {
|
|
208
301
|
const config = fixture({
|
|
209
302
|
cargoManifest: `[package]\nname = "fixture"\nversion = "0.0.0"\nedition = "2021"\n[dependencies]\nrightkit-license = "=0.1.2"`,
|
|
@@ -11,8 +11,12 @@ import {
|
|
|
11
11
|
validateRightKitCargoContract,
|
|
12
12
|
} from "./cargo-contract.mjs";
|
|
13
13
|
import { assertAsrAdapterPair } from "./asr-artifact-adoption.mjs";
|
|
14
|
+
import { resolveConfiguredBuildInputs } from "./release-invocation.mjs";
|
|
14
15
|
|
|
15
|
-
const workspace = path.resolve(
|
|
16
|
+
const workspace = path.resolve(
|
|
17
|
+
process.env.RIGHT_SUITE_CONTRACT_WORKSPACE
|
|
18
|
+
?? new URL("../../../..", import.meta.url).pathname.replace(/^\/(\w:)/, "$1"),
|
|
19
|
+
);
|
|
16
20
|
// The Right Suite web layer's repo + filesystem path is `rightsites` post the 2026-07-15 naming lock;
|
|
17
21
|
// a not-yet-renamed checkout may still have `rightapps`. Resolve whichever exists so the contract
|
|
18
22
|
// passes on both. (The runtime service namespace stays `rightapps` — that is deliberately untouched.)
|
|
@@ -59,6 +63,10 @@ function assertMacPackageEntry(packageCommand, scripts, label) {
|
|
|
59
63
|
assert.doesNotMatch(implementation, /\bpnpm(?:\s+run)?\s+rightkit:package:mac\b/, `${label} rightkit:package:mac must not recurse into itself`);
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
function assertBuildInputs(config, target, label) {
|
|
67
|
+
resolveConfiguredBuildInputs(config, target, label);
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
function resolveCargoVersionContract(versionManifest, canonicalVersions) {
|
|
63
71
|
const consumer = new Map(Object.entries(versionManifest.cargo ?? {}));
|
|
64
72
|
const staged = new Map(Object.entries(versionManifest.stagedCargo ?? {}));
|
|
@@ -389,10 +397,20 @@ test("mac release config uses a dedicated non-recursive package entry point", ()
|
|
|
389
397
|
);
|
|
390
398
|
});
|
|
391
399
|
|
|
400
|
+
test("every app platform requires effective non-empty build inputs", () => {
|
|
401
|
+
assert.doesNotThrow(() => assertBuildInputs(
|
|
402
|
+
{ buildInputs: { include: ["src/**"] } },
|
|
403
|
+
{},
|
|
404
|
+
"fixture mac",
|
|
405
|
+
));
|
|
406
|
+
assert.throws(() => assertBuildInputs({}, {}, "fixture win"), /fixture win must declare non-empty buildInputs\.include paths/);
|
|
407
|
+
assert.throws(() => assertBuildInputs({}, { buildInputs: { include: [] } }, "fixture mac"), /fixture mac must declare non-empty buildInputs\.include paths/);
|
|
408
|
+
});
|
|
409
|
+
|
|
392
410
|
test("RightKit exposes one current version manifest", () => {
|
|
393
411
|
assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
|
|
394
412
|
assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
|
|
395
|
-
assert.equal(versions.npm["@rightkit/release"], "0.2.
|
|
413
|
+
assert.equal(versions.npm["@rightkit/release"], "0.2.42");
|
|
396
414
|
assert.equal(versions.npm["@rightkit/legal"], "0.2.0");
|
|
397
415
|
assert.equal(versions.npm["@rightkit/license"], "0.1.5");
|
|
398
416
|
assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
|
|
@@ -404,8 +422,12 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
404
422
|
"@rightkit/license": "0.1.6",
|
|
405
423
|
});
|
|
406
424
|
assert.deepEqual(versions.legacyNpm, {
|
|
407
|
-
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31"],
|
|
425
|
+
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41"],
|
|
408
426
|
});
|
|
427
|
+
assert.ok(
|
|
428
|
+
new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.41"),
|
|
429
|
+
"the previously published @rightkit/release 0.2.41 must remain accepted during the 0.2.42 rollout",
|
|
430
|
+
);
|
|
409
431
|
const licensePackage = JSON.parse(readFileSync(
|
|
410
432
|
path.join(workspace, "tools/rightkit/packages/license/package.json"),
|
|
411
433
|
"utf8",
|
|
@@ -486,6 +508,7 @@ for (const app of apps) {
|
|
|
486
508
|
assert.ok(config.version);
|
|
487
509
|
for (const platform of ["mac", "win"]) {
|
|
488
510
|
const target = config.targets[platform];
|
|
511
|
+
assertBuildInputs(config, target, `${app.key} ${platform}`);
|
|
489
512
|
assert.equal(target.signed, true);
|
|
490
513
|
assert.equal(target.upload, undefined, "generic uploads bypass tier manifest routing");
|
|
491
514
|
assert.equal(target.publish.cmd, "right-release");
|
package/rightkit-versions.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"@rightkit/logs": "0.1.3",
|
|
8
8
|
"@rightkit/platform-ui": "0.1.0",
|
|
9
9
|
"@rightkit/qa": "0.1.0",
|
|
10
|
-
"@rightkit/release": "0.2.
|
|
10
|
+
"@rightkit/release": "0.2.42",
|
|
11
11
|
"@rightkit/tauri": "0.1.0",
|
|
12
12
|
"@rightkit/updates": "0.2.3"
|
|
13
13
|
},
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"@rightkit/license": "0.1.6"
|
|
18
18
|
},
|
|
19
19
|
"legacyNpm": {
|
|
20
|
-
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31"]
|
|
20
|
+
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41"]
|
|
21
21
|
},
|
|
22
22
|
"cargo": {
|
|
23
23
|
"rightkit-license": "0.1.2",
|
|
@@ -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
|
+
});
|