@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.
@@ -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("reports dirty files that can change the packaged app", () => {
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 invocationRoot = path.dirname(path.resolve(configName));
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, path.resolve(configName)).replaceAll("\\", "/");
50
+ const relativeConfig = path.relative(repoRoot, configPath).replaceAll("\\", "/");
49
51
  if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
50
- const vaultRoot = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) }).vaultRoot;
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
- if (!dryRun) git(repoRoot, ["fetch", "origin", "main"]);
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 worktree = path.join(vaultRoot, "worktrees", `${platform}-${shortCommit}`);
72
- if (!existsSync(worktree)) {
73
- if (dryRun) fail(`dry-run needs an existing worktree: ${worktree}`);
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 dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, invocationRoot, relativeConfig, target.buildInputs ?? config.buildInputs), commit);
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 (!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");
170
167
  else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
171
- fail(`worktree target exists and is not the release cache link: ${targetLink}`);
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", worktreeConfigPath, "--platform", platform, "--no-upload"],
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: path.dirname(worktreeConfigPath), sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey });
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, relativeConfig, configured) {
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
  }
@@ -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.39",
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": {
@@ -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
- ...inputs.include.map((pattern) => pathspec(pattern)),
60
- ...(inputs.exclude ?? []).map((pattern) => pathspec(pattern, true)),
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 [...new Set(files)]
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 });
@@ -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.39");
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
- assert.equal(pkg.scripts["release:patch:mac"], "right-release --platform mac --tier patch");
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
- assert.equal(config.targets.mac.package.cmd, "pnpm", `${app.key} must use the shared pnpm package entry point`);
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
  }
@@ -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.39",
10
+ "@rightkit/release": "0.2.41",
11
11
  "@rightkit/tauri": "0.1.0",
12
12
  "@rightkit/updates": "0.2.3"
13
13
  },
@@ -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
+ });