@rightkit/release 0.2.54 → 0.2.55

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
@@ -121,10 +121,17 @@ try {
121
121
  if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
122
122
  const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
123
123
  const inputHashes = hashInputs(requiredInputs);
124
+ const receiptRoot = path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(appRoot, ".right-release", "receipts"));
125
+ const signingIdentity = platform === "win" ? {
126
+ contract: target.signingContract ?? "<missing>",
127
+ configSha256: hashFile(configPath),
128
+ receiptInputs: ["raw-exe", "installer"].map((phase) => path.join(receiptRoot, `windows-${phase}.json`)),
129
+ } : null;
130
+ if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
124
131
  const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
125
132
  const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
126
133
  const cacheIdentity = resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
127
- const cacheKey = cacheIdentity.fingerprint;
134
+ const cacheKey = hashFileText(JSON.stringify({ cache: cacheIdentity.fingerprint, signingIdentity })).slice(0, 16);
128
135
  const cacheMode = process.env.RIGHT_RELEASE_CACHE_MODE ?? (platform === "mac" || platform === "win" ? "shared" : "legacy");
129
136
  if (cacheMode !== "legacy" && cacheMode !== "shared") fail("RIGHT_RELEASE_CACHE_MODE must be legacy or shared");
130
137
  const sharedCacheRoot = cacheMode === "shared" ? resolveSharedCacheRoot({ platform, env: process.env }) : undefined;
@@ -179,6 +186,7 @@ try {
179
186
  platform,
180
187
  cacheKey,
181
188
  inputs: inputHashes,
189
+ signingIdentity,
182
190
  tools: toolVersions,
183
191
  createdAt: new Date().toISOString(),
184
192
  };
@@ -246,7 +254,7 @@ try {
246
254
  checkpoint(stateRoot, "hardened");
247
255
  },
248
256
  seal: async ({ sealedDir }) => {
249
- sealRelease({ configRoot: appRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey });
257
+ sealRelease({ configRoot: appRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity });
250
258
  verifySealedRelease(sealedDir);
251
259
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
252
260
  checkpoint(stateRoot, "sealed");
@@ -275,6 +283,10 @@ function hashFile(file) {
275
283
  return createHash("sha256").update(readFileSync(file)).digest("hex");
276
284
  }
277
285
 
286
+ function hashFileText(text) {
287
+ return createHash("sha256").update(text).digest("hex");
288
+ }
289
+
278
290
  function sqlCipherFeatures(text) {
279
291
  return [...text.matchAll(/features\s*=\s*\[([^\]]+)\]/g)]
280
292
  .flatMap((match) => match[1].match(/"([^"]+)"/g) ?? [])
@@ -282,7 +294,7 @@ function sqlCipherFeatures(text) {
282
294
  .filter((value) => /sqlcipher|openssl/i.test(value));
283
295
  }
284
296
 
285
- function sealRelease({ configRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey }) {
297
+ function sealRelease({ configRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity }) {
286
298
  const sources = new Map();
287
299
  const addSource = (source, role) => {
288
300
  const current = sources.get(source) ?? new Set();
@@ -327,6 +339,7 @@ function sealRelease({ configRoot, sealedDir, releaseId, config, target, platfor
327
339
  commit,
328
340
  platform,
329
341
  cacheKey,
342
+ signing: signingIdentity ? { ...signingIdentity, receipts: signingIdentity.receiptInputs.map((file) => ({ file, sha256: hashFile(file) })) } : null,
330
343
  files,
331
344
  routes,
332
345
  inputs: inputHashes,
@@ -60,3 +60,14 @@ test("shared Cache V2 target bridge is owned by a finally-cleaned lifecycle", ()
60
60
  assert.match(source, /targetBridge\.run\(.*runBuildStateMachine/s);
61
61
  assert.match(source, /if \(cacheMode === "shared"\) targetBridge\.ensure\(\)/);
62
62
  });
63
+
64
+ test("Windows seal and cache identity bind the signing contract, config, and receipts", () => {
65
+ assert.match(source, /signingIdentity = platform === "win"/);
66
+ assert.match(source, /contract: target\.signingContract/);
67
+ assert.match(source, /configSha256: hashFile\(configPath\)/);
68
+ assert.match(source, /windows-\$\{phase\}\.json/);
69
+ assert.match(source, /inputHashes\["\.right-release\/signing-identity\.json"\]/);
70
+ assert.match(source, /cache: cacheIdentity\.fingerprint, signingIdentity/);
71
+ assert.match(source, /receipts: signingIdentity\.receiptInputs\.map/);
72
+ assert.match(readFileSync(new URL("./release.mjs", import.meta.url), "utf8"), /item\.after\?\.sha256 !== currentHashes/);
73
+ });
@@ -48,8 +48,8 @@ test("Cargo guard resolves real Cargo through rustup or explicit override", () =
48
48
  const cargo = resolveRealCargo({ env: {}, run: (command, args) => { calls.push([command, args]); return { status: 0, stdout: "/real/cargo\n" }; } });
49
49
  assert.equal(cargo, path.resolve("/real/cargo"));
50
50
  assert.deepEqual(calls[0][1], ["which", "cargo"]);
51
- assert.equal(resolveRealRustc({ env: { RIGHTSUITE_REAL_RUSTC: "/opt/toolchain/rustc" } }), "/opt/toolchain/rustc");
52
- assert.equal(resolveRealRustc({ env: {}, run: () => ({ status: 0, stdout: "/real/rustc\n" }) }), "/real/rustc");
51
+ assert.equal(resolveRealRustc({ env: { RIGHTSUITE_REAL_RUSTC: "/opt/toolchain/rustc" } }), path.resolve("/opt/toolchain/rustc"));
52
+ assert.equal(resolveRealRustc({ env: {}, run: () => ({ status: 0, stdout: "/real/rustc\n" }) }), path.resolve("/real/rustc"));
53
53
  });
54
54
 
55
55
  function cacheEnv(overrides = {}) {
@@ -81,7 +81,7 @@ test("Cargo guard rejects cache escapes and alternate compiler wrappers", async
81
81
  test("Cargo guard routes heavy and light commands without recursion", async () => {
82
82
  const calls = [];
83
83
  const options = {
84
- env: { TEST: "1", RIGHT_RELEASE_CACHE_ROOT: "/cache" },
84
+ env: { TEST: "1", ...cacheEnv() },
85
85
  policyOptions: { exists: () => false },
86
86
  resolveCargo: () => "/real/cargo",
87
87
  runHeavy: async (args, config) => { calls.push(["heavy", args, config.env]); return 0; },
@@ -90,7 +90,8 @@ test("Cargo guard routes heavy and light commands without recursion", async () =
90
90
  assert.equal(await runCargoGuard(["test"], options), 0);
91
91
  assert.equal(await runCargoGuard(["fmt", "--check"], options), 0);
92
92
  assert.deepEqual(calls[0].slice(0, 2), ["heavy", ["--", "/real/cargo", "test"]]);
93
- assert.equal(calls[0][2].CARGO_TARGET_DIR.startsWith("/cache/dev-targets/"), true);
93
+ const cacheRoot = cacheEnv().RIGHT_RELEASE_CACHE_ROOT ?? path.join(cacheEnv().LOCALAPPDATA, "RightSuite", "Cache", "release");
94
+ assert.equal(calls[0][2].CARGO_TARGET_DIR.startsWith(path.join(path.resolve(cacheRoot), "dev-targets") + path.sep), true);
94
95
  assert.equal(calls[0][2].RUSTC_WRAPPER, "sccache");
95
96
  assert.deepEqual(calls[1], ["light", "/real/cargo", ["fmt", "--check"], options.env]);
96
97
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.54",
3
+ "version": "0.2.55",
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": {
@@ -1,14 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
+ import path from "node:path";
2
3
  import test from "node:test";
3
4
  import { resolvePrimaryWorktree } from "./release-invocation.mjs";
4
5
 
5
6
  test("resolves a submodule primary worktree from core.worktree", () => {
6
7
  assert.equal(resolvePrimaryWorktree({
7
- repoRoot: "/suite/membrane",
8
+ repoRoot: path.resolve("/suite/membrane"),
8
9
  worktrees: "worktree /suite/.git/modules/membrane\nHEAD abc\n",
9
10
  gitDir: "/suite/.git/modules/membrane",
10
11
  coreWorktree: "../../../membrane",
11
- }), "/suite/membrane");
12
+ }), path.resolve("/suite/membrane"));
12
13
  });
13
14
 
14
15
  test("preserves an ordinary primary worktree", () => {
package/release.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { access, readFile, readdir } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
3
4
  import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -25,6 +26,7 @@ const RIGHTKIT_ALLOWED = buildAllowedVersions(
25
26
  );
26
27
  const RIGHTKIT_CARGO_ALLOWED = buildAllowedVersions(RIGHTKIT_VERSIONS.cargo, RIGHTKIT_VERSIONS.stagedCargo);
27
28
  const FORBIDDEN_RIGHTKIT_SPEC = /^(?:git|file|link|workspace):|github|github\.com/i;
29
+ const WINDOWS_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-nsis-v1";
28
30
 
29
31
  const args = process.argv.slice(2);
30
32
  const opts = {
@@ -98,6 +100,12 @@ if (target.signed !== true) {
98
100
  if (opts.platform === "win" && !target.sign?.files?.length) {
99
101
  fail(`${config.app ?? "app"} win must declare sign.files for the signed release pipeline`);
100
102
  }
103
+ if (opts.platform === "win") {
104
+ if (target.signingContract !== WINDOWS_SIGNING_CONTRACT) fail(`${config.app ?? "app"} win must declare signingContract: ${WINDOWS_SIGNING_CONTRACT}`);
105
+ if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} win must declare prePackage for the raw EXE build`);
106
+ if (target.sign?.prePackageFiles?.length !== 1) fail(`${config.app ?? "app"} win must declare exactly one sign.prePackageFiles raw EXE`);
107
+ if (target.sign.prePackageFiles.some((file) => target.sign.files.includes(file))) fail(`${config.app ?? "app"} win raw EXE and installer signing files must be distinct`);
108
+ }
101
109
  if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
102
110
  /**
103
111
  * Assemble preflight inputs from the app's own files. Kept here (not in
@@ -185,6 +193,13 @@ if (!opts.skipChecks) {
185
193
  for (const script of config.checks ?? []) await runPackageScript(config.packageManager, script, workdir);
186
194
  }
187
195
 
196
+ if (opts.platform === "win") {
197
+ const rawFiles = target.sign.prePackageFiles.map((p) => path.resolve(root, p));
198
+ await runCommand(target.prePackage, root);
199
+ for (const file of rawFiles) await mustExist(file, `missing raw EXE signing artifact: ${file}`);
200
+ await signWindows(rawFiles, "raw-exe", root);
201
+ }
202
+
188
203
  await runCommand(command, root);
189
204
 
190
205
  for (const rel of target.artifacts ?? []) {
@@ -194,13 +209,14 @@ for (const rel of target.artifacts ?? []) {
194
209
  if (opts.platform === "win" && target.sign?.files?.length) {
195
210
  const files = target.sign.files.map((p) => path.resolve(root, p));
196
211
  for (const file of files) await mustExist(file, `missing signing artifact: ${file}`);
197
- await run("node", [SIGN_WINDOWS, ...(opts.dryRun ? ["--dry-run"] : []), ...files], root);
212
+ await signWindows(files, "installer", root);
213
+ if (target.postSign) await runCommand(target.postSign, root);
198
214
  const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => path.resolve(root, artifact.file)))];
199
215
  if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
200
216
  await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
201
217
  }
202
218
 
203
- if (target.postSign) {
219
+ if (target.postSign && opts.platform !== "win") {
204
220
  await runCommand(target.postSign, root);
205
221
  }
206
222
 
@@ -377,6 +393,32 @@ function expandEnv(value) {
377
393
  return String(value).replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "");
378
394
  }
379
395
 
396
+ async function signWindows(files, phase, root) {
397
+ const args = [SIGN_WINDOWS];
398
+ const receipt = path.join(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts"), `windows-${phase}.json`);
399
+ if (opts.dryRun) args.push("--dry-run");
400
+ else {
401
+ if (existsSync(receipt)) unlinkSync(receipt);
402
+ args.push("--receipt", receipt);
403
+ }
404
+ console.log(`right-release: signing windows ${phase}`);
405
+ await run("node", [...args, ...files], root);
406
+ if (!opts.dryRun) verifyWindowsReceipt(receipt, files, phase);
407
+ }
408
+
409
+ function verifyWindowsReceipt(receipt, files, phase) {
410
+ if (!existsSync(receipt)) fail(`missing ${phase} Windows signing receipt: ${receipt}`);
411
+ let evidence;
412
+ try { evidence = JSON.parse(readFileSync(receipt, "utf8")); } catch { fail(`invalid ${phase} Windows signing receipt: ${receipt}`); }
413
+ if (evidence.schema !== 1 || !Array.isArray(evidence.files) || evidence.files.length !== files.length) fail(`ambiguous ${phase} Windows signing receipt: ${receipt}`);
414
+ const expected = files.map((file) => path.resolve(file)).sort();
415
+ const actual = evidence.files.map((item) => path.resolve(item.file)).sort();
416
+ const currentHashes = new Map(files.map((file) => [path.resolve(file), createHash("sha256").update(readFileSync(file)).digest("hex")]));
417
+ if (JSON.stringify(expected) !== JSON.stringify(actual) || evidence.files.some((item) => item.authenticode !== "Valid" || item.subject !== "CN=Damned Ventures LLC" || item.timestampPresent !== true || item.after?.sha256 !== currentHashes.get(path.resolve(item.file)))) {
418
+ fail(`invalid ${phase} Windows signing receipt evidence: ${receipt}`);
419
+ }
420
+ }
421
+
380
422
  function run(cmd, runArgs, cwd, env = {}, options = {}) {
381
423
  const printable = `${cmd} ${runArgs.join(" ")}`.trim();
382
424
  const releaseEnv = opts.tier ? { RIGHT_RELEASE_TIER: opts.tier } : {};
package/release.test.mjs CHANGED
@@ -16,7 +16,7 @@ function git(cwd, ...args) {
16
16
  return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
17
17
  }
18
18
 
19
- function fixture({ signed = true, publish = false, packageJson, cargoManifest, cargoConfig, repoCargoConfig, siblingCargoManifest, buildInputs = defaultBuildInputs, platform = "win" } = {}) {
19
+ function fixture({ signed = true, publish = false, signingContract = "windows-raw-exe-authenticode-before-nsis-v1", prePackageFiles = ["raw.exe"], packageJson, cargoManifest, cargoConfig, repoCargoConfig, siblingCargoManifest, buildInputs = defaultBuildInputs, platform = "win" } = {}) {
20
20
  const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), "right-release-test-"));
21
21
  const dir = repoCargoConfig ? path.join(fixtureRoot, "apps", "fixture") : fixtureRoot;
22
22
  mkdirSync(dir, { recursive: true });
@@ -52,7 +52,7 @@ function fixture({ signed = true, publish = false, packageJson, cargoManifest, c
52
52
  package: { cmd: "node", args: ["-e", "process.exit(0)"] },
53
53
  ...(publish ? { publish: { cmd: "node", args: ["publish-update.mjs"] } } : {}),
54
54
  artifacts: [],
55
- ...(platform === "win" ? { sign: { files: ["fixture.exe"] } } : {}),
55
+ ...(platform === "win" ? { signingContract, prePackage: { cmd: "node", args: ["-e", "process.exit(0)"] }, sign: { prePackageFiles, files: ["fixture.exe"] } } : {}),
56
56
  updater: { artifacts: [{ file: "fixture.exe", signature: "fixture.exe.sig", platform: "windows-x86_64", key: "fixture/fixture.exe" }] },
57
57
  hardening: [],
58
58
  },
@@ -185,6 +185,14 @@ test("rejects targets that do not explicitly declare signed release output", ()
185
185
  assert.match(result.stderr, /signed: true/);
186
186
  });
187
187
 
188
+ test("rejects Windows targets with missing or ambiguous signing configuration", () => {
189
+ for (const options of [{ signingContract: null }, { prePackageFiles: [] }, { prePackageFiles: ["raw.exe", "other.exe"] }]) {
190
+ const result = run(fixture(options), "--tier=patch");
191
+ assert.notEqual(result.status, 0);
192
+ assert.match(result.stderr, /signingContract|prePackageFiles/i);
193
+ }
194
+ });
195
+
188
196
  test("rejects Git, path, or link RightKit app dependencies before release work starts", () => {
189
197
  const result = run(
190
198
  fixture({
@@ -348,13 +356,19 @@ test("combined build and upload is rejected before the package step", () => {
348
356
  assert.doesNotMatch(result.stdout, /node -e process\.exit\(0\)/);
349
357
  });
350
358
 
351
- test("Windows tier-neutral build signs the installer then minisigns the updater payload", () => {
359
+ test("Windows tier-neutral build builds raw EXE, signs it, bundles, signs installer, then minisigns updater payload", () => {
352
360
  const result = run(fixture({ publish: true }));
353
361
  assert.equal(result.status, 0, result.stderr);
354
- const azureAt = result.stdout.indexOf("sign-windows.mjs");
362
+ const rawBuildAt = result.stdout.indexOf("node -e");
363
+ const rawSignAt = result.stdout.indexOf("signing windows raw-exe");
364
+ const bundleAt = result.stdout.indexOf("node -e", rawSignAt);
365
+ const installerSignAt = result.stdout.indexOf("signing windows installer");
355
366
  const updaterAt = result.stdout.indexOf("sign-updater.mjs");
356
- assert.ok(azureAt >= 0, result.stdout);
357
- assert.ok(updaterAt > azureAt, result.stdout);
367
+ assert.ok(rawBuildAt >= 0, result.stdout);
368
+ assert.ok(rawSignAt > rawBuildAt, result.stdout);
369
+ assert.ok(bundleAt > rawSignAt, result.stdout);
370
+ assert.ok(installerSignAt > bundleAt, result.stdout);
371
+ assert.ok(updaterAt > installerSignAt, result.stdout);
358
372
  assert.doesNotMatch(result.stdout, /publish-update\.mjs/);
359
373
  });
360
374
 
@@ -450,6 +450,7 @@ test("every app platform requires effective non-empty build inputs", () => {
450
450
 
451
451
  test("RightKit exposes one current version manifest", () => {
452
452
  assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
453
+ assert.equal(versions.schema, 2);
453
454
  assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
454
455
  assert.equal(versions.npm["@rightkit/release"], "0.2.47");
455
456
  assert.equal(versions.npm["@rightkit/legal"], "0.2.0");
@@ -458,23 +459,30 @@ test("RightKit exposes one current version manifest", () => {
458
459
  assert.equal(versions.npm["@rightkit/tauri"], "0.1.0");
459
460
  assert.equal(versions.npm["@rightkit/updates"], "0.2.3");
460
461
  assert.deepEqual(versions.stagedNpm, {
462
+ "@rightkit/ax": "0.2.0",
463
+ "@rightkit/git": "0.2.0",
461
464
  "@rightkit/legal": "0.3.0",
462
- "@rightkit/legal-ui": "0.1.0",
465
+ "@rightkit/legal-ui": "0.1.1",
463
466
  "@rightkit/license": "0.1.6",
464
- "@rightkit/release": "0.2.54",
467
+ "@rightkit/release": "0.2.55",
465
468
  });
466
469
  assert.deepEqual(versions.legacyNpm, {
467
- "@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", "0.2.49", "0.2.50", "0.2.51", "0.2.53"],
470
+ "@rightkit/legal-ui": ["0.1.0"],
471
+ "@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", "0.2.49", "0.2.50", "0.2.51", "0.2.53", "0.2.54"],
468
472
  });
469
473
  assert.ok(
470
474
  new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
471
475
  "previously published @rightkit/release versions must remain accepted during the 0.2.46 rollout",
472
476
  );
473
- const licensePackage = JSON.parse(readFileSync(
474
- path.join(workspace, "tools/rightkit/packages/license/package.json"),
475
- "utf8",
476
- ));
477
- assert.equal(licensePackage.version, versions.stagedNpm["@rightkit/license"]);
477
+ for (const [name, version] of Object.entries(versions.stagedNpm)) {
478
+ const packageName = name.slice("@rightkit/".length);
479
+ const manifest = JSON.parse(readFileSync(
480
+ path.join(workspace, "tools/rightkit/packages", packageName, "package.json"),
481
+ "utf8",
482
+ ));
483
+ assert.equal(manifest.name, name);
484
+ assert.equal(manifest.version, version, `${name} package version must equal stagedNpm`);
485
+ }
478
486
  assert.equal(versions.cargo["rightkit-license"], "0.1.2");
479
487
  assert.equal(versions.cargo["rightkit-logs"], "0.1.0");
480
488
  assert.equal(versions.cargo["rightkit-tauri"], "0.1.0");
@@ -653,19 +661,15 @@ for (const app of macOnlyApps) {
653
661
  args: ["publish-update", "--config", "right-release.config.mjs", "--platform", "mac"],
654
662
  });
655
663
  const updaters = config.targets.mac.updater.artifacts;
656
- assert.deepEqual(
657
- updaters.map(({ platform }) => platform).sort(),
658
- ["darwin-aarch64", "darwin-x86_64"],
659
- `${app.key} universal DMG must serve both Mac architectures`,
660
- );
661
- const updater = updaters[0];
662
- assert.match(updater.file, /\.dmg$/);
663
- assert.equal(updater.signature, `${updater.file}.sig`);
664
- assert.equal(updater.key, `${app.key}/updates/mac/current/ScreenRight.dmg`);
665
- for (const candidate of updaters) {
666
- assert.equal(candidate.file, updater.file, `${app.key} updater rows must share one universal DMG`);
667
- assert.equal(candidate.signature, updater.signature, `${app.key} updater rows must share one signature`);
668
- assert.equal(candidate.key, updater.key, `${app.key} updater rows must share one current R2 object`);
664
+ assert.equal(updaters.length, 2);
665
+ assert.deepEqual(updaters.map((updater) => updater.platform).sort(), ["darwin-aarch64", "darwin-x86_64"]);
666
+ assert.equal(new Set(updaters.map((updater) => updater.file)).size, 1, `${app.key} universal updater must share one DMG`);
667
+ assert.equal(new Set(updaters.map((updater) => updater.signature)).size, 1, `${app.key} universal updater must share one signature`);
668
+ assert.equal(new Set(updaters.map((updater) => updater.key)).size, 1, `${app.key} universal updater must share one current R2 object`);
669
+ for (const updater of updaters) {
670
+ assert.match(updater.file, /\.dmg$/);
671
+ assert.equal(updater.signature, `${updater.file}.sig`);
672
+ assert.equal(updater.key, `${app.key}/updates/mac/current/ScreenRight.dmg`);
669
673
  }
670
674
  assertBuildInputs(config, config.targets.mac, `${app.key} mac`);
671
675
  assertMacPackageEntry(config.targets.mac.package, pkg.scripts, app.key);
@@ -1,5 +1,5 @@
1
1
  {
2
- "schema": 1,
2
+ "schema": 2,
3
3
  "packageManager": "pnpm@11.18.0",
4
4
  "npm": {
5
5
  "@rightkit/git": "0.2.0",
@@ -13,13 +13,16 @@
13
13
  "@rightkit/updates": "0.2.3"
14
14
  },
15
15
  "stagedNpm": {
16
+ "@rightkit/ax": "0.2.0",
17
+ "@rightkit/git": "0.2.0",
16
18
  "@rightkit/legal": "0.3.0",
17
- "@rightkit/legal-ui": "0.1.0",
19
+ "@rightkit/legal-ui": "0.1.1",
18
20
  "@rightkit/license": "0.1.6",
19
- "@rightkit/release": "0.2.54"
21
+ "@rightkit/release": "0.2.55"
20
22
  },
21
23
  "legacyNpm": {
22
- "@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", "0.2.49", "0.2.50", "0.2.51", "0.2.53"]
24
+ "@rightkit/legal-ui": ["0.1.0"],
25
+ "@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", "0.2.49", "0.2.50", "0.2.51", "0.2.53", "0.2.54"]
23
26
  },
24
27
  "cargo": {
25
28
  "rightkit-license": "0.1.2",
package/sign-windows.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { spawnSync } from "node:child_process";
@@ -19,9 +20,14 @@ const userEnvCache = new Map();
19
20
  const args = process.argv.slice(2);
20
21
  const dryRun = args.includes("--dry-run");
21
22
  const verifyOnly = args.includes("--verify-only");
22
- const files = args.filter((arg) => arg !== "--dry-run" && arg !== "--verify-only").map((arg) => path.resolve(arg));
23
+ const receiptIndex = args.indexOf("--receipt");
24
+ const receiptPath = receiptIndex >= 0 ? args[receiptIndex + 1] : null;
25
+ const files = args
26
+ .filter((arg, index) => arg !== "--dry-run" && arg !== "--verify-only" && arg !== "--receipt" && (receiptIndex < 0 || index !== receiptIndex + 1))
27
+ .map((arg) => path.resolve(arg));
23
28
 
24
29
  if (!files.length) fail("usage: node sign-windows.mjs [--dry-run|--verify-only] <file>...");
30
+ if (receiptIndex >= 0 && !receiptPath) fail("--receipt requires a path");
25
31
  if (dryRun) {
26
32
  console.log(`dry-run: ${verifyOnly ? "Authenticode verification" : "Azure Artifact Signing"} ${files.join(", ")}`);
27
33
  process.exit(0);
@@ -33,7 +39,7 @@ const childEnv = signingEnv();
33
39
  const signtool = findFirst([env("AZURE_SIGNTOOL_PATH"), env("SIGNTOOL_PATH"), ...signtoolCandidates()]);
34
40
  if (!signtool) fail("signtool.exe not found; set AZURE_SIGNTOOL_PATH or install Windows SDK Build Tools");
35
41
  if (verifyOnly) {
36
- for (const file of files) run(signtool, ["verify", "/pa", "/v", file]);
42
+ for (const file of files) assertVerification(file, run(signtool, ["verify", "/pa", "/v", file]));
37
43
  process.exit(0);
38
44
  }
39
45
  const dlib = findFirst([
@@ -47,14 +53,22 @@ if (!dlib) fail("Azure.CodeSigning.Dlib.dll not found; set AZURE_CODESIGN_DLIB_P
47
53
 
48
54
  const tempDir = mkdtempSync(path.join(os.tmpdir(), "right-sign-"));
49
55
  const metadata = metadataPath(tempDir);
56
+ const evidence = [];
50
57
  try {
51
58
  for (const file of files) {
59
+ const before = fileEvidence(file);
52
60
  run(signtool, ["sign", "/v", "/fd", "SHA256", "/tr", "http://timestamp.acs.microsoft.com", "/td", "SHA256", "/dlib", dlib, "/dmdf", metadata, file]);
53
- run(signtool, ["verify", "/pa", "/v", file]);
61
+ const verification = assertVerification(file, run(signtool, ["verify", "/pa", "/v", file]));
62
+ evidence.push({ file, before, after: fileEvidence(file), ...verification });
54
63
  }
55
64
  } finally {
56
65
  rmSync(tempDir, { recursive: true, force: true });
57
66
  }
67
+ if (receiptPath) {
68
+ const absoluteReceipt = path.resolve(receiptPath);
69
+ mkdirSync(path.dirname(absoluteReceipt), { recursive: true });
70
+ writeFileSync(absoluteReceipt, `${JSON.stringify({ schema: 1, files: evidence }, null, 2)}\n`);
71
+ }
58
72
 
59
73
  function metadataPath(dir) {
60
74
  const existing = env("AZURE_ARTIFACT_SIGNING_METADATA") || env("AZURE_SIGNING_METADATA");
@@ -128,8 +142,23 @@ function userEnv(name) {
128
142
  }
129
143
 
130
144
  function run(cmd, runArgs) {
131
- const result = spawnSync(cmd, runArgs, { env: childEnv, stdio: "inherit" });
145
+ const result = spawnSync(cmd, runArgs, { env: childEnv, encoding: "utf8", windowsHide: true });
146
+ if (result.stdout) process.stdout.write(result.stdout);
147
+ if (result.stderr) process.stderr.write(result.stderr);
132
148
  if (result.status !== 0) process.exit(result.status ?? 1);
149
+ return `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
150
+ }
151
+
152
+ function assertVerification(file, output) {
153
+ if (!/successfully verified/i.test(output)) fail(`Authenticode verification did not report Valid for ${file}`);
154
+ if (!/^Issued to:\s*Damned Ventures LLC\s*$/im.test(output)) fail(`Authenticode subject must be Damned Ventures LLC for ${file}`);
155
+ if (!/(?:timestamp|time stamped|timestamped)/i.test(output)) fail(`Authenticode timestamp is missing for ${file}`);
156
+ return { authenticode: "Valid", subject: "CN=Damned Ventures LLC", timestampPresent: true };
157
+ }
158
+
159
+ function fileEvidence(file) {
160
+ const bytes = readFileSync(file);
161
+ return { sha256: createHash("sha256").update(bytes).digest("hex"), sizeBytes: statSync(file).size };
133
162
  }
134
163
 
135
164
  function fail(message) {
@@ -0,0 +1,32 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "generatedAt": "2026-08-10T04:42:36.444Z",
4
+ "workRoot": "C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR",
5
+ "apps": [
6
+ {
7
+ "key": "viewright",
8
+ "remote": "https://github.com/bogusyogi/viewright.git",
9
+ "appDir": ".",
10
+ "revision": "21a4171fa8add2d8114bc5f07498b60d7e8eafe5",
11
+ "packageManager": "pnpm@11.18.0",
12
+ "clone": {
13
+ "command": "git clone --depth 1 --single-branch https://github.com/bogusyogi/viewright.git C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright",
14
+ "status": 0,
15
+ "stdout": "",
16
+ "stderr": "Cloning into 'C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright'...\nUpdating files: 91% (1902/2080)\rUpdating files: 92% (1914/2080)\rUpdating files: 93% (1935/2080)\rUpdating files: 94% (1956/2080)\rUpdating files: 95% (1976/2080)\rUpdating files: 96% (1997/2080)\rUpdating files: 97% (2018/2080)\rUpdating files: 98% (2039/2080)\rUpdating files: 99% (2060/2080)\rUpdating files: 100% (2080/2080)\rUpdating files: 100% (2080/2080), done."
17
+ },
18
+ "install": {
19
+ "command": "C:\\nvm4w\\nodejs\\node.exe C:\\nvm4w\\nodejs\\node_modules\\pnpm\\bin\\pnpm.mjs install --frozen-lockfile",
20
+ "status": 0,
21
+ "stdout": "✓ Lockfile passes supply-chain policies (verified 8h ago)\nLockfile is up to date, resolution step is skipped\nProgress: resolved 1, reused 0, downloaded 0, added 0\nPackages: +619\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\nProgress: resolved 619, reused 0, downloaded 0, added 0\nProgress: resolved 619, reused 60, downloaded 0, added 0\nPackages are hard linked from the content-addressable store to the virtual store.\n Content-addressable store is at: C:\\Users\\adrds\\AppData\\Local\\pnpm\\store\\v11\n Virtual store is at: node_modules/.pnpm\nProgress: resolved 619, reused 573, downloaded 7, added 7\nProgress: resolved 619, reused 574, downloaded 9, added 10\nProgress: resolved 619, reused 574, downloaded 22, added 11\nProgress: resolved 619, reused 574, downloaded 23, added 11\nProgress: resolved 619, reused 574, downloaded 26, added 14\nProgress: resolved 619, reused 574, downloaded 30, added 24\nProgress: resolved 619, reused 574, downloaded 31, added 57\nProgress: resolved 619, reused 574, downloaded 34, added 128\nProgress: resolved 619, reused 574, downloaded 38, added 135\nProgress: resolved 619, reused 574, downloaded 38, added 136\nProgress: resolved 619, reused 574, downloaded 39, added 141\nProgress: resolved 619, reused 574, downloaded 39, added 148\nProgress: resolved 619, reused 574, downloaded 39, added 182\nProgress: resolved 619, reused 574, downloaded 40, added 201\nProgress: resolved 619, reused 574, downloaded 41, added 218\nProgress: resolved 619, reused 574, downloaded 41, added 242\nProgress: resolved 619, reused 574, downloaded 42, added 312\nProgress: resolved 619, reused 574, downloaded 42, added 371\nProgress: resolved 619, reused 574, downloaded 42, added 430\nProgress: resolved 619, reused 574, downloaded 42, added 486\nProgress: resolved 619, reused 574, downloaded 43, added 543\nProgress: resolved 619, reused 574, downloaded 43, added 578\nProgress: resolved 619, reused 574, downloaded 44, added 596\nProgress: resolved 619, reused 574, downloaded 44, added 611\nProgress: resolved 619, reused 574, downloaded 44, added 612\nProgress: resolved 619, reused 574, downloaded 44, added 613\nProgress: resolved 619, reused 574, downloaded 44, added 615\nProgress: resolved 619, reused 574, downloaded 44, added 616\nProgress: resolved 619, reused 574, downloaded 44, added 617\nProgress: resolved 619, reused 574, downloaded 44, added 618\nProgress: resolved 619, reused 574, downloaded 45, added 618\nProgress: resolved 619, reused 574, downloaded 45, added 619\nProgress: resolved 619, reused 574, downloaded 45, added 619, done\n\ndependencies:\n+ @codemirror/autocomplete 6.20.3\n+ @codemirror/commands 6.10.4\n+ @codemirror/lang-html 6.4.11\n+ @codemirror/lang-javascript 6.2.5\n+ @codemirror/lang-markdown 6.5.0\n+ @codemirror/language 6.12.4\n+ @codemirror/lint 6.9.7\n+ @codemirror/search 6.7.1\n+ @codemirror/state 6.7.1\n+ @codemirror/view 6.43.6\n+ @eigenpal/docx-editor-agents @eigenpal/docx-editor-agents@file:vendor/docx-editor/packages/agents(react@19.2.7)\n+ @eigenpal/docx-editor-core @eigenpal/docx-editor-core@file:vendor/docx-editor/packages/core(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)\n+ @eigenpal/docx-editor-i18n @eigenpal/docx-editor-i18n@file:vendor/docx-editor/packages/i18n\n+ @eigenpal/docx-editor-react @eigenpal/docx-editor-react@file:vendor/docx-editor/packages/react(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)\n+ @lezer/highlight 1.2.3\n+ @mdx-js/mdx 3.1.1\n+ @phosphor-icons/react 2.1.10\n+ @radix-ui/react-select 2.3.2\n+ @rightkit/legal-ui 0.1.0\n+ @rightkit/license 0.1.6\n+ @rightkit/logs 0.1.3\n+ @rightkit/tauri 0.1.0\n+ @rightkit/updates 0.2.3\n+ @tauri-apps/api 2.11.1\n+ @tauri-apps/plugin-http 2.5.9\n+ @tauri-apps/plugin-process 2.3.1\n+ @tauri-apps/plugin-updater 2.10.1\n+ clsx 2.1.1\n+ docxtemplater 3.69.0\n+ dompurify 3.4.13\n+ fabric 7.4.0\n+ github-slugger 2.0.0\n+ jszip 3.10.1\n+ katex 0.17.0\n+ mermaid 11.16.1\n+ pdfjs-dist 6.2.108\n+ pizzip 3.2.0\n+ prosemirror-commands 1.7.1\n+ prosemirror-dropcursor 1.8.2\n+ prosemirror-history 1.5.0\n+ prosemirror-keymap 1.2.3\n+ prosemirror-model 1.25.10\n+ prosemirror-state 1.4.4\n+ prosemirror-tables 1.8.5\n+ prosemirror-transform 1.12.0\n+ prosemirror-view 1.42.0\n+ react 19.2.7\n+ react-dom 19.2.7\n+ react-image-crop 11.1.2\n+ rehype-stringify 10.0.1\n+ remark-frontmatter 5.0.0\n+ remark-gfm 4.0.1\n+ remark-math 6.0.0\n+ remark-parse 11.0.0\n+ remark-rehype 11.1.2\n+ remark-smartypants 3.0.2\n+ shiki 4.3.1\n+ sonner 2.0.7\n+ sucrase 3.35.1\n+ unified 11.0.5\n+ xml-js 1.6.11\n+ yaml 2.9.0\n\ndevDependencies:\n+ @biomejs/biome 2.5.3\n+ @rightkit/legal 0.3.0\n+ @rightkit/release 0.2.50\n+ @tailwindcss/vite 4.3.2\n+ @tauri-apps/cli 2.11.4\n+ @testing-library/dom 10.4.1\n+ @testing-library/jest-dom 6.9.1\n+ @testing-library/react 16.3.2\n+ @types/mdast 4.0.4\n+ @types/react 19.2.17\n+ @types/react-dom 19.2.3\n+ @types/ws 8.18.1\n+ @vitejs/plugin-react 6.0.3\n+ happy-dom 20.10.6\n+ jscpd 5.0.12\n+ jsdom 29.1.1\n+ knip 6.25.0\n+ tailwindcss 4.3.2\n+ typescript 6.0.3\n+ vite 8.1.4\n+ vitest 4.1.10\n+ ws 8.21.0\n\nDone in 40.8s using pnpm v11.18.0",
22
+ "stderr": ""
23
+ },
24
+ "doctor": {
25
+ "command": "C:\\nvm4w\\nodejs\\node.exe C:\\nvm4w\\nodejs\\node_modules\\pnpm\\bin\\pnpm.mjs release:doctor",
26
+ "status": 0,
27
+ "stdout": "right-release 0.2.50\nconfig: C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright\\right-release.config.mjs\napp: viewright\nplatform: win\ntier: <required for release/publish>\npackageManager: pnpm\nworkdir: C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright\nhardeningscan: C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright\\node_modules\\.pnpm\\@rightkit+release@0.2.50\\node_modules\\@rightkit\\release\\hardeningscan.mjs\nlegal: C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright\\legal\\legal-manifest.json\nlegalAcceptance: viewright-2026-07-17-v3\nlegalManifestSha256: 035ea7cbd040ef001dbdc1385f114c8bb126178fea9a9f705016a726f96a7830\nsign: src-tauri/target/release/bundle/nsis/ViewRight_0.1.60_x64-setup.exe\npreflight:\n [ok ] target-bridge: src-tauri/target is ready for the shared cache bridge\n [ok ] version: 0.1.60 is free to build\n [ok ] windows-sdk: makeappx.exe from SDK 10.0.26100.0\n [ok ] signtool: signtool.exe from SDK 10.0.26100.0\n [ok ] sccache: sccache 0.17.0\n [ok ] disk: 628.7GB free",
28
+ "stderr": "$ right-release doctor"
29
+ }
30
+ }
31
+ ]
32
+ }
package/target-bridge.mjs CHANGED
@@ -168,10 +168,26 @@ function isReclaimableLink(link, ownedRoot, realpath, platform) {
168
168
  if (error?.code === "ENOENT") return true;
169
169
  throw error;
170
170
  }
171
- const root = canonical(ownedRoot);
171
+ const root = canonicalPlannedPath(ownedRoot, realpath, canonical);
172
172
  return resolved === root || resolved.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`);
173
173
  }
174
174
 
175
+ function canonicalPlannedPath(value, realpath, canonical) {
176
+ let cursor = path.resolve(value);
177
+ const missing = [];
178
+ while (true) {
179
+ try {
180
+ return canonical(path.join(realpath(cursor), ...missing.reverse()));
181
+ } catch (error) {
182
+ if (error?.code !== "ENOENT") throw error;
183
+ const parent = path.dirname(cursor);
184
+ if (parent === cursor) throw error;
185
+ missing.push(path.basename(cursor));
186
+ cursor = parent;
187
+ }
188
+ }
189
+ }
190
+
175
191
  function sameRealPath(left, right, realpath, platform) {
176
192
  const canonical = (value) => {
177
193
  const resolved = path.normalize(realpath(value));