@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 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 = inputPaths(appRoot, configPath, target);
83
- const dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, appRoot, target.buildInputs ?? config.buildInputs, requiredInputs), commit);
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 (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
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`);
@@ -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.40",
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": {
@@ -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 configPath = path.resolve(opts.config);
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 fixture({ signed = true, publish = false, packageJson, cargoManifest, cargoConfig, repoCargoConfig, siblingCargoManifest } = {}) {
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(new URL("../../../..", import.meta.url).pathname.replace(/^\/(\w:)/, "$1"));
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.40");
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");
@@ -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.40",
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
+ });