@rightkit/release 0.2.46 → 0.2.47

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
@@ -173,7 +173,14 @@ try {
173
173
  createdAt: new Date().toISOString(),
174
174
  };
175
175
  const targetLink = path.join(appRoot, "src-tauri", "target");
176
- const targetBridge = createTargetBridge({ link: targetLink, target: env.CARGO_TARGET_DIR });
176
+ // ownedRoot is the per-app cache parent holding every fingerprint dir, so a
177
+ // link left by an earlier fingerprint is recognised as ours and repointed
178
+ // instead of hard-stopping the build.
179
+ const targetBridge = createTargetBridge({
180
+ link: targetLink,
181
+ target: env.CARGO_TARGET_DIR,
182
+ ownedRoot: path.dirname(env.CARGO_TARGET_DIR),
183
+ });
177
184
 
178
185
  const result = await targetBridge.run(async () => runBuildStateMachine({
179
186
  root: repoRoot,
@@ -45,7 +45,11 @@ test("dirty release config is rejected before the config module is imported", ()
45
45
  });
46
46
 
47
47
  test("shared Cache V2 target bridge is owned by a finally-cleaned lifecycle", () => {
48
- assert.match(source, /createTargetBridge\(\{ link: targetLink, target: env\.CARGO_TARGET_DIR \}\)/);
48
+ assert.match(source, /createTargetBridge\(\{\s*link: targetLink,\s*target: env\.CARGO_TARGET_DIR,/);
49
+ // ownedRoot must stay wired: without it the bridge refuses every link left by
50
+ // an earlier fingerprint, so any Cargo.lock or version bump hard-stops the
51
+ // next build until someone deletes the link by hand.
52
+ assert.match(source, /ownedRoot: path\.dirname\(env\.CARGO_TARGET_DIR\)/);
49
53
  assert.match(source, /targetBridge\.run\(.*runBuildStateMachine/s);
50
54
  assert.match(source, /if \(cacheMode === "shared"\) targetBridge\.ensure\(\)/);
51
55
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.46",
3
+ "version": "0.2.47",
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": {
@@ -432,7 +432,7 @@ test("every app platform requires effective non-empty build inputs", () => {
432
432
  test("RightKit exposes one current version manifest", () => {
433
433
  assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
434
434
  assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
435
- assert.equal(versions.npm["@rightkit/release"], "0.2.46");
435
+ assert.equal(versions.npm["@rightkit/release"], "0.2.47");
436
436
  assert.equal(versions.npm["@rightkit/legal"], "0.2.0");
437
437
  assert.equal(versions.npm["@rightkit/license"], "0.1.5");
438
438
  assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
@@ -444,7 +444,7 @@ test("RightKit exposes one current version manifest", () => {
444
444
  "@rightkit/license": "0.1.6",
445
445
  });
446
446
  assert.deepEqual(versions.legacyNpm, {
447
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45"],
447
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46"],
448
448
  });
449
449
  assert.ok(
450
450
  new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
@@ -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.46",
10
+ "@rightkit/release": "0.2.47",
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", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45"]
20
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46"]
21
21
  },
22
22
  "cargo": {
23
23
  "rightkit-license": "0.1.2",
package/target-bridge.mjs CHANGED
@@ -1,9 +1,19 @@
1
1
  import { lstatSync, mkdirSync, realpathSync, symlinkSync, unlinkSync } from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ /**
5
+ * @param ownedRoot Directory this bridge owns (the app's per-fingerprint cache
6
+ * parent). An existing link resolving INSIDE it was created by this system for
7
+ * an earlier fingerprint, so it is safe to relink: a symlink is a pointer, not
8
+ * data, and the directory it pointed at is untouched. Without this, every
9
+ * fingerprint change — i.e. every Cargo.lock or version bump — left a stale
10
+ * link that hard-stopped the next build until someone deleted it by hand.
11
+ * Omit it to keep the strict refuse-everything behavior.
12
+ */
4
13
  export function createTargetBridge({
5
14
  link,
6
15
  target,
16
+ ownedRoot,
7
17
  platform = process.platform,
8
18
  lstat = lstatSync,
9
19
  mkdir = mkdirSync,
@@ -29,9 +39,40 @@ export function createTargetBridge({
29
39
  ownership = entryIdentity(entry);
30
40
  created = true;
31
41
  } 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}`);
42
+ if (!entry.isSymbolicLink()) {
43
+ throw new Error(
44
+ `primary checkout target is not a symbolic link; refusing to replace it: ${link}\n` +
45
+ ` It is a real directory holding build output. Delete it yourself if that output is disposable, then re-run.`,
46
+ );
47
+ }
48
+ // A dangling link (its cache entry was pruned) makes realpath throw, so
49
+ // this comparison must tolerate ENOENT rather than crash before the
50
+ // reclaim path below gets a chance to handle exactly that case.
51
+ let pointsAtTarget = false;
52
+ try {
53
+ pointsAtTarget = sameRealPath(link, target, realpath, platform);
54
+ } catch (error) {
55
+ if (error?.code !== "ENOENT") throw error;
56
+ }
57
+ if (!pointsAtTarget) {
58
+ // Stale link from an earlier fingerprint, or one whose cache entry a
59
+ // prune already removed. Both are ours to repoint.
60
+ if (isReclaimableLink(link, ownedRoot, realpath, platform)) {
61
+ unlink(link);
62
+ symlink(target, link, platform === "win32" ? "junction" : "dir");
63
+ entry = lstat(link);
64
+ if (!sameRealPath(link, target, realpath, platform)) {
65
+ throw new Error(`RightKit target bridge did not repoint to the owned shared cache target: ${link}`);
66
+ }
67
+ ownership = entryIdentity(entry);
68
+ created = true;
69
+ } else {
70
+ throw new Error(
71
+ `primary checkout target is not the owned shared cache target; refusing to replace it: ${link}\n` +
72
+ ` It is a symlink pointing outside this app's release cache, so it was not created by RightKit.\n` +
73
+ ` Repoint or remove it yourself, then re-run.`,
74
+ );
75
+ }
35
76
  }
36
77
  }
37
78
  acquired = true;
@@ -110,6 +151,29 @@ function readEntry(file, lstat) {
110
151
  }
111
152
  }
112
153
 
154
+ /**
155
+ * True when `link` is a symlink RightKit may repoint: it resolves inside
156
+ * `ownedRoot`, or it dangles entirely (its cache entry was pruned). A link
157
+ * resolving anywhere else is someone else's and is never touched.
158
+ */
159
+ function isReclaimableLink(link, ownedRoot, realpath, platform) {
160
+ if (!ownedRoot) return false;
161
+ const canonical = (value) => {
162
+ const resolved = path.normalize(value);
163
+ return platform === "win32" ? resolved.toLowerCase() : resolved;
164
+ };
165
+ let resolved;
166
+ try {
167
+ resolved = canonical(realpath(link));
168
+ } catch (error) {
169
+ // Dangling link: the entry it pointed at is gone, so nothing can be lost.
170
+ if (error?.code === "ENOENT") return true;
171
+ throw error;
172
+ }
173
+ const root = canonical(ownedRoot);
174
+ return resolved === root || resolved.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`);
175
+ }
176
+
113
177
  function sameRealPath(left, right, realpath, platform) {
114
178
  const canonical = (value) => {
115
179
  const resolved = path.normalize(realpath(value));
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
@@ -153,3 +153,54 @@ test("cleanup is idempotent and reports process errors without masking a build f
153
153
  assert.equal(lstatSync(fx.link).isSymbolicLink(), true);
154
154
  unlinkSync(fx.link);
155
155
  });
156
+
157
+ test("a stale link from an earlier fingerprint is repointed, not a hard stop", () => {
158
+ const dir = mkdtempSync(path.join(os.tmpdir(), "bridge-stale-"));
159
+ const ownedRoot = path.join(dir, "cache", "heardright");
160
+ const oldTarget = path.join(ownedRoot, "aaaaaaaa");
161
+ const newTarget = path.join(ownedRoot, "bbbbbbbb");
162
+ mkdirSync(oldTarget, { recursive: true });
163
+ const link = path.join(dir, "target");
164
+ symlinkSync(oldTarget, link, "junction");
165
+
166
+ const bridge = createTargetBridge({ link, target: newTarget, ownedRoot });
167
+ const result = bridge.ensure();
168
+
169
+ assert.equal(result.created, true);
170
+ assert.equal(realpathSync(link), realpathSync(newTarget));
171
+ // The directory the stale link pointed at must survive: it is cache, and the
172
+ // link was only ever a pointer to it.
173
+ assert.equal(existsSync(oldTarget), true);
174
+ rmSync(dir, { recursive: true, force: true });
175
+ });
176
+
177
+ test("a link pointing outside the owned root is still refused", () => {
178
+ const dir = mkdtempSync(path.join(os.tmpdir(), "bridge-foreign-"));
179
+ const ownedRoot = path.join(dir, "cache", "heardright");
180
+ const foreign = path.join(dir, "somewhere-else");
181
+ const newTarget = path.join(ownedRoot, "bbbbbbbb");
182
+ mkdirSync(foreign, { recursive: true });
183
+ const link = path.join(dir, "target");
184
+ symlinkSync(foreign, link, "junction");
185
+
186
+ const bridge = createTargetBridge({ link, target: newTarget, ownedRoot });
187
+ assert.throws(() => bridge.ensure(), /refusing to replace it/);
188
+ assert.equal(realpathSync(link), realpathSync(foreign));
189
+ rmSync(dir, { recursive: true, force: true });
190
+ });
191
+
192
+ test("a dangling link whose cache entry was pruned is reclaimed", () => {
193
+ const dir = mkdtempSync(path.join(os.tmpdir(), "bridge-dangling-"));
194
+ const ownedRoot = path.join(dir, "cache", "heardright");
195
+ const pruned = path.join(ownedRoot, "aaaaaaaa");
196
+ const newTarget = path.join(ownedRoot, "bbbbbbbb");
197
+ mkdirSync(pruned, { recursive: true });
198
+ const link = path.join(dir, "target");
199
+ symlinkSync(pruned, link, "junction");
200
+ rmSync(pruned, { recursive: true, force: true });
201
+
202
+ const bridge = createTargetBridge({ link, target: newTarget, ownedRoot });
203
+ assert.equal(bridge.ensure().created, true);
204
+ assert.equal(realpathSync(link), realpathSync(newTarget));
205
+ rmSync(dir, { recursive: true, force: true });
206
+ });