@rightkit/release 0.2.141 → 0.2.143

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/README.md CHANGED
@@ -72,6 +72,10 @@ Build a SwiftPM app with `right-release build --platform mac|win` (add `--config
72
72
 
73
73
  Set `targets.mac.notarize.appBeforePackage: true` (with `notarize.app` naming the `.app`) to notarize and staple the app itself right after signing and before the package step copies it into the DMG, so an app copied out of the DMG passes `stapler validate` offline. The DMG named by `notarize.file` is still notarized and stapled afterward; the app ticket receipt is `macos-app-notarization.json`. Without the flag, behavior is unchanged.
74
74
 
75
+ For `projectType: "swiftpm"` on mac with `appBeforePackage: true` and no `targets.mac.package`, right-release builds the DMG itself (same `dmg-build.mjs` as Tauri) at the package step, after the `.app` is signed, notarized and stapled: it runs `xcrun stapler validate` on the app (refusing to pack an unstapled one), builds the DMG at `notarize.file` (must end `.dmg`), Developer ID signs it (receipt `macos-dmg-signing.json`), then notarizes and staples the DMG as before. Options go in `targets.mac.dmg`: `{ volname, background, iconSize, appPosition, applicationsPosition, window, volumeIcon, out }` (`volname` defaults to the app name). A prePackage hook must no longer build the DMG: preflight fails when its command or arguments mention `dmg` or `hdiutil` (heuristic), because that DMG would carry the unstapled app. Keep a custom DMG flow with `targets.mac.dmg: false` or a `targets.mac.package` step (both run after the staple and skip the automatic build).
76
+
77
+ `sign.prePackageFiles` entries may be `.app` bundles (directories). Nested code (Mach-O files, frameworks, XPC services, plug-ins) is signed first, inside-out and without entitlements, then the bundle is signed; the receipt records a tree digest of the bundle. Any other directory is rejected with a clear error.
78
+
75
79
  ## Known issues
76
80
 
77
81
  - `smol-toml` is pinned exactly. A consumer that adds another `smol-toml` gets a second copy (npm audit 2026-10-08, release finding).
package/build-release.mjs CHANGED
@@ -48,7 +48,7 @@ import { macosCodesignEnvironment } from "./codesign-environment.mjs";
48
48
  // and test-only edits and would force a cold Rust rebuild of every app each time.
49
49
  // Cached target directories must not outlive changes to these signing helpers.
50
50
 
51
- const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "codesign-environment.mjs", "codesign-shim.mjs", "codesign-argv.mjs", "osascript-guard.mjs", "dmg-build.mjs", "dmg-layout.mjs", "rightkit-signing-agent.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs", "msix-build.mjs", "msix-plan.mjs"];
51
+ const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "codesign-environment.mjs", "codesign-shim.mjs", "codesign-argv.mjs", "osascript-guard.mjs", "dmg-build.mjs", "dmg-layout.mjs", "swift-mac-dmg.mjs", "mac-bundle-signing.mjs", "macos-file-evidence.mjs", "swift-mac-app.mjs", "rightkit-signing-agent.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs", "msix-build.mjs", "msix-plan.mjs"];
52
52
  const PIPELINE_FINGERPRINT = createHash("sha256")
53
53
  .update(PIPELINE_FINGERPRINT_SOURCES.map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
54
54
  .digest("hex")
package/install-app.mjs CHANGED
@@ -2,12 +2,12 @@
2
2
  // Sanctioned, non-intrusive local app install from a signed DMG (macOS); --dev also accepts a
3
3
  // Developer ID-signed, not-notarized zip or .app.
4
4
  // Never uses a shell, osascript, AppleEvents, or foreground activation.
5
- import { execFile as nodeExecFile } from "./background-process.mjs";
5
+ import { execFile as nodeExecFile, execFileSync as nodeExecFileSync } from "./background-process.mjs";
6
6
  import { detachWithRetry } from "./hdiutil-detach.mjs";
7
7
  import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
- import { pathToFileURL } from "node:url";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
11
 
12
12
  const SIGTERM_WAIT_MS = 15_000;
13
13
  const POLL_MS = 250;
@@ -194,6 +194,59 @@ export function writeJournal(fsImpl, file, data) {
194
194
  } catch { /* directory fsync is best effort */ }
195
195
  }
196
196
 
197
+ // --- atomic swap helper (rightkit-swap: renamex_np(RENAME_SWAP)) -------------
198
+
199
+ // Errors that mean "no atomic swap here": fall back to the two-rename + journal path.
200
+ const SWAP_FALLBACK_CODES = new Set(["ENOHELPER", "ENOTSUP", "EINVAL", "ENOSYS"]);
201
+ const SWAP_HELPER_NAME = "rightkit-swap";
202
+
203
+ // Resolve order: env RIGHTKIT_SWAP, then <package>/bin/darwin/rightkit-swap, then PATH.
204
+ export function resolveSwapHelper({ env = process.env, fsImpl = fs, pkgDir = path.dirname(fileURLToPath(import.meta.url)) } = {}) {
205
+ const usable = (p) => { try { return fsImpl.statSync(p).isFile(); } catch { return false; } };
206
+ if (env.RIGHTKIT_SWAP && usable(env.RIGHTKIT_SWAP)) return env.RIGHTKIT_SWAP;
207
+ const shipped = path.join(pkgDir, "bin", "darwin", SWAP_HELPER_NAME);
208
+ if (usable(shipped)) return shipped;
209
+ for (const dir of String(env.PATH || "").split(path.delimiter)) {
210
+ if (dir && path.isAbsolute(dir) && usable(path.join(dir, SWAP_HELPER_NAME))) return path.join(dir, SWAP_HELPER_NAME);
211
+ }
212
+ return null;
213
+ }
214
+
215
+ function coded(code, message) {
216
+ const error = new Error(message);
217
+ error.code = code;
218
+ return error;
219
+ }
220
+
221
+ // Sync on purpose: signal-handler rollback must be able to swap back. Throws an error whose `code`
222
+ // is in SWAP_FALLBACK_CODES when no atomic swap is possible here (no helper, or exit 3 / ENOTSUP / EINVAL).
223
+ export function createSwapPaths({ env = process.env, resolve = resolveSwapHelper, execFileSync = nodeExecFileSync } = {}) {
224
+ return (a, b) => {
225
+ const helper = resolve({ env });
226
+ if (!helper) throw coded("ENOHELPER", `${SWAP_HELPER_NAME} helper not found`);
227
+ try { execFileSync(helper, [a, b], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
228
+ catch (error) {
229
+ const text = `${error?.stderr || ""}`.trim();
230
+ if (error?.code === "ENOENT" || error?.code === "EACCES") throw coded("ENOHELPER", `${helper} cannot run: ${error.code}`);
231
+ if (error?.status === 3 || /SWAP_UNSUPPORTED/.test(text)) throw coded("ENOTSUP", text || "atomic swap unsupported on this volume");
232
+ throw coded("EIO", text || error?.message || "rightkit-swap failed");
233
+ }
234
+ };
235
+ }
236
+
237
+ // Exchange two paths, falling back to three renames only when no atomic swap exists (roll-back/recovery).
238
+ function swapBack(swapPaths, fsImpl, a, b) {
239
+ try { swapPaths(a, b); return "atomic"; }
240
+ catch (error) {
241
+ if (!SWAP_FALLBACK_CODES.has(error?.code)) throw error;
242
+ const tmp = `${a}.swapback`;
243
+ fsImpl.renameSync(a, tmp);
244
+ fsImpl.renameSync(b, a);
245
+ fsImpl.renameSync(tmp, b);
246
+ return "two-rename";
247
+ }
248
+ }
249
+
197
250
  const defaultIsAlive = (pid) => {
198
251
  try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
199
252
  };
@@ -207,7 +260,7 @@ async function trashBundle(run, fsImpl, home, now, bundle, appName) {
207
260
  }
208
261
 
209
262
  // Deterministic roll-forward/back of any interrupted install journal in `dest`. Idempotent.
210
- export async function recoverInstalls({ dest, appName = null, run, fsImpl, home, now, event, sleep, isAlive = defaultIsAlive, selfPid = process.pid }) {
263
+ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home, now, event, sleep, isAlive = defaultIsAlive, selfPid = process.pid, swapPaths = createSwapPaths() }) {
211
264
  let names = [];
212
265
  try { names = fsImpl.readdirSync(dest).filter((n) => JOURNAL_RE.test(n)); } catch { return []; }
213
266
  if (appName) names = names.filter((n) => n === path.basename(journalPathFor(dest, appName)));
@@ -225,8 +278,28 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
225
278
  const rm = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
226
279
  let action;
227
280
  let keepJournal = false;
281
+ let keepStaged = false;
228
282
  try {
229
- if (!has(j.installed)) {
283
+ if (j.swap === "atomic") {
284
+ // Atomic mode: `installed` is never absent. After the exchange, `staged` holds the OLD bundle;
285
+ // before it, `staged` holds the new copy. The staged copy's inode (journaled) tells them apart.
286
+ const swapped = has(j.installed) && Number.isFinite(j.stagedIno) && fsImpl.statSync(j.installed).ino === j.stagedIno;
287
+ if (!has(j.installed)) {
288
+ if (has(j.staged)) { fsImpl.renameSync(j.staged, j.installed); action = "restored-backup"; }
289
+ else action = "cleared";
290
+ } else if (!swapped || !has(j.staged)) action = "cleared";
291
+ else {
292
+ let ok = j.phase === "verified";
293
+ if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
294
+ if (ok) {
295
+ try { await trashBundle(run, fsImpl, home, now, j.staged, path.basename(j.installed)); action = "rolled-forward"; }
296
+ catch { action = "rolled-forward-trash-pending"; keepJournal = true; keepStaged = true; }
297
+ } else {
298
+ swapBack(swapPaths, fsImpl, j.staged, j.installed); // staged now holds the bad new bundle
299
+ action = "rolled-back";
300
+ }
301
+ }
302
+ } else if (!has(j.installed)) {
230
303
  if (has(j.backup)) { fsImpl.renameSync(j.backup, j.installed); action = "restored-backup"; }
231
304
  else action = "cleared";
232
305
  } else if (has(j.backup)) {
@@ -245,7 +318,7 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
245
318
  event("recovery-failed", { journal: file, reason: errText(error) });
246
319
  continue; // leave the journal for the next run
247
320
  }
248
- rm(j.staged);
321
+ if (!keepStaged) rm(j.staged);
249
322
  if (j.mount && has(j.mount)) {
250
323
  const out = await detachWithRetry({ mount: j.mount, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
251
324
  if (out.ok) rm(j.mountRoot);
@@ -258,9 +331,9 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
258
331
  }
259
332
 
260
333
  export async function recoverApp(opts, deps = {}) {
261
- const { execFile = realExecFile, fsImpl = fs, home = os.homedir(), now = () => new Date(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), emit = () => {}, isAlive, platform = process.platform } = deps;
334
+ const { execFile = realExecFile, fsImpl = fs, home = os.homedir(), now = () => new Date(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), emit = () => {}, isAlive, platform = process.platform, env = process.env, swapPaths = createSwapPaths({ env }) } = deps;
262
335
  if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
263
- const results = await recoverInstalls({ dest: opts.dest, appName: opts.appName, run: (c, a) => execFile(c, a), fsImpl, home, now, event: (name, data = {}) => emit({ event: name, ...data }), sleep, isAlive });
336
+ const results = await recoverInstalls({ dest: opts.dest, appName: opts.appName, run: (c, a) => execFile(c, a), fsImpl, home, now, event: (name, data = {}) => emit({ event: name, ...data }), sleep, isAlive, swapPaths });
264
337
  emit({ event: "recover-done", dest: opts.dest, recovered: results.length });
265
338
  return { ok: true, recovered: results };
266
339
  }
@@ -280,6 +353,8 @@ export async function installApp(opts, deps = {}) {
280
353
  isAlive = defaultIsAlive,
281
354
  signalTarget = process,
282
355
  exit = (code) => process.exit(code),
356
+ env = process.env,
357
+ swapPaths = createSwapPaths({ env }),
283
358
  } = deps;
284
359
  const dev = opts.dev === true;
285
360
  const event = (name, data = {}) => emit({ event: name, ...(dev ? { dev: true } : {}), ...data });
@@ -298,7 +373,16 @@ export async function installApp(opts, deps = {}) {
298
373
  const s = swap;
299
374
  swap = null;
300
375
  try {
301
- if (!s.final) {
376
+ if (s.mode === "atomic") {
377
+ // installed was never absent. Before the exchange only the staged copy needs removing; after it,
378
+ // exchange back (installed = old again, staged = the new copy) and drop the new copy. Once final,
379
+ // staged holds the verified-old bundle awaiting the Trash, so it must not be touched.
380
+ if (!s.final) {
381
+ if (s.swapped) swapBack(swapPaths, fsImpl, s.staged, s.installed);
382
+ removeQuiet(s.staged);
383
+ removeQuiet(s.journal);
384
+ }
385
+ } else if (!s.final) {
302
386
  if (s.stagedIn) removeQuiet(s.installed);
303
387
  if (s.backedUp && !fsImpl.existsSync(s.installed)) fsImpl.renameSync(s.backup, s.installed);
304
388
  removeQuiet(s.staged);
@@ -346,7 +430,7 @@ export async function installApp(opts, deps = {}) {
346
430
  if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
347
431
  if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
348
432
 
349
- await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid });
433
+ await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid, swapPaths });
350
434
 
351
435
  let sourceApp = null;
352
436
  const pickApp = (dir, label) => {
@@ -408,7 +492,7 @@ export async function installApp(opts, deps = {}) {
408
492
  const journalData = { schema: 1, appName, pid, phase: "staging", dev, installed, backup, staged, mount: mountPoint, mountRoot };
409
493
  removeQuiet(staged);
410
494
  writeJournal(fsImpl, journal, journalData);
411
- swap = { installed, backup, staged, journal, backedUp: false, stagedIn: false, final: false };
495
+ swap = { installed, backup, staged, journal, mode: null, swapped: false, backedUp: false, stagedIn: false, final: false };
412
496
  try { await run("/usr/bin/ditto", [sourceApp, staged]); }
413
497
  catch (error) {
414
498
  rollbackSwap();
@@ -426,12 +510,37 @@ export async function installApp(opts, deps = {}) {
426
510
  const quit = hadInstalled ? await quitApp(run, kill, sleep, installed) : { pids: [], killed: [] };
427
511
  event("quit", { wasRunning, pids: quit.pids, forced: quit.killed });
428
512
 
429
- // Swap: old -> backup, staged -> installed. The old bundle is never trashed before the new one is verified.
513
+ // Swap. Preferred: one atomic exchange (renamex_np RENAME_SWAP), so `installed` is never absent even
514
+ // under SIGKILL; the old bundle then sits at the staged path. Fallback (no helper, or a volume without
515
+ // RENAME_SWAP): old -> backup, staged -> installed, journaled. The old bundle is never trashed before
516
+ // the new one is verified.
430
517
  failedStage = "replace";
431
- writeJournal(fsImpl, journal, { ...journalData, phase: "swapping" });
432
- if (hadInstalled) { fsImpl.renameSync(installed, backup); swap.backedUp = true; }
433
- fsImpl.renameSync(staged, installed);
434
- swap.stagedIn = true;
518
+ let jd = journalData;
519
+ let swapMode;
520
+ if (!hadInstalled) {
521
+ swapMode = "rename"; // nothing to exchange: a single rename onto an absent name is atomic
522
+ swap.mode = "two-rename";
523
+ writeJournal(fsImpl, journal, { ...jd, phase: "swapping", swap: swapMode });
524
+ fsImpl.renameSync(staged, installed);
525
+ swap.stagedIn = true;
526
+ } else {
527
+ jd = { ...journalData, swap: "atomic", backup: null, stagedIno: fsImpl.statSync(staged).ino };
528
+ writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
529
+ swap.mode = "atomic";
530
+ try { swapPaths(staged, installed); swap.swapped = true; swapMode = "atomic"; }
531
+ catch (error) {
532
+ if (!SWAP_FALLBACK_CODES.has(error?.code)) throw new InstallAppError("swap-failed", `atomic swap failed (${errText(error)}); previous app untouched`);
533
+ event("swap-fallback", { reason: error.code, detail: errText(error) });
534
+ swapMode = "two-rename";
535
+ swap.mode = "two-rename";
536
+ jd = { ...journalData, swap: "two-rename" };
537
+ writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
538
+ fsImpl.renameSync(installed, backup); swap.backedUp = true;
539
+ fsImpl.renameSync(staged, installed);
540
+ swap.stagedIn = true;
541
+ }
542
+ if (swapMode === "atomic") writeJournal(fsImpl, journal, { ...jd, phase: "swapped" });
543
+ }
435
544
  // Dev builds are not notarized: clear quarantine on the NEW bundle only so Gatekeeper does not block it.
436
545
  if (dev) {
437
546
  try { await run("/usr/bin/xattr", ["-dr", "com.apple.quarantine", installed]); }
@@ -443,18 +552,19 @@ export async function installApp(opts, deps = {}) {
443
552
  // Re-verify installed copy; on failure the catch below rolls back to the backup.
444
553
  failedStage = "reverify";
445
554
  await verifyBundle(run, installed, { assess: !dev, staple: false });
446
- writeJournal(fsImpl, journal, { ...journalData, phase: "verified", mount: null, mountRoot: null });
555
+ writeJournal(fsImpl, journal, { ...jd, phase: "verified", mount: null, mountRoot: null });
447
556
  swap.final = true;
448
557
 
449
558
  // Only now does the old bundle go to the Trash.
450
559
  let trashed = null;
451
- if (backup) {
452
- try { trashed = await trashBundle(run, fsImpl, home, now, backup, appName); }
453
- catch (error) { event("backup-trash-failed", { backup, reason: errText(error) }); trashed = backup; }
560
+ const oldBundle = swapMode === "atomic" ? staged : backup; // atomic: the exchange left the old bundle at the staged path
561
+ if (hadInstalled && oldBundle) {
562
+ try { trashed = await trashBundle(run, fsImpl, home, now, oldBundle, appName); }
563
+ catch (error) { event("backup-trash-failed", { backup: oldBundle, reason: errText(error) }); trashed = oldBundle; }
454
564
  }
455
- if (!backup || trashed !== backup) removeQuiet(journal);
565
+ if (!oldBundle || trashed !== oldBundle) removeQuiet(journal);
456
566
  swap = null;
457
- event("replaced", { installed, previous: trashed });
567
+ event("replaced", { installed, previous: trashed, swap: swapMode });
458
568
 
459
569
  // Relaunch hidden and in the background only.
460
570
  const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
@@ -463,7 +573,7 @@ export async function installApp(opts, deps = {}) {
463
573
  await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
464
574
  event("relaunched", { bundleId });
465
575
  }
466
- const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
576
+ const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, swap: swapMode, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
467
577
  event("done", result);
468
578
  return result;
469
579
  } catch (error) {
@@ -0,0 +1,33 @@
1
+ // Directory entries (.app bundles) in `sign.prePackageFiles`: sign nested code inside-out first
2
+ // (no entitlements), then the bundle itself, reusing the swift-mac-app inside-out discovery.
3
+ import { existsSync, readFileSync, statSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { discoverNestedCode } from "./swift-mac-app.mjs";
6
+
7
+ const fail = (message) => Object.assign(new Error(message), { code: "MAC_SIGN_ENTRY_INVALID" });
8
+
9
+ function mainExecutableName(app) {
10
+ try {
11
+ const plist = readFileSync(path.join(app, "Contents", "Info.plist"), "utf8");
12
+ const match = /<key>CFBundleExecutable<\/key>\s*<string>([^<]+)<\/string>/.exec(plist);
13
+ if (match) return match[1];
14
+ } catch { /* binary plist or unreadable: fall back to the bundle name */ }
15
+ return path.basename(app, ".app");
16
+ }
17
+
18
+ /**
19
+ * Split signing entries into nested code (deepest first, signed without entitlements) and the entries
20
+ * themselves. A directory must be a `.app` bundle; anything else is rejected with a clear error.
21
+ * Injectable: isDirectory, discover, exists.
22
+ */
23
+ export function expandMacSigningEntries(files, { isDirectory = (f) => statSync(f).isDirectory(), exists = existsSync, discover = discoverNestedCode, mainName = mainExecutableName } = {}) {
24
+ const nested = [];
25
+ for (const file of files) {
26
+ if (!isDirectory(file)) continue;
27
+ if (!file.endsWith(".app") || !exists(path.join(file, "Contents", "Info.plist"))) {
28
+ throw fail(`sign.prePackageFiles entry is a directory but not a .app bundle with Contents/Info.plist: ${file}`);
29
+ }
30
+ nested.push(...discover(file, mainName(file)));
31
+ }
32
+ return { nested: [...new Set(nested)], files };
33
+ }
@@ -0,0 +1,27 @@
1
+ // Content evidence for a macOS signing entry: a plain file hashes its bytes; a directory (.app bundle)
2
+ // hashes a deterministic tree digest. Shared by sign-macos.mjs (writes the receipt) and release.mjs (verifies it).
3
+ import { createHash } from "node:crypto";
4
+ import { lstatSync, readdirSync, readFileSync, readlinkSync, statSync } from "node:fs";
5
+ import path from "node:path";
6
+
7
+ export function fileEvidence(file) {
8
+ if (statSync(file).isDirectory()) {
9
+ const digest = createHash("sha256");
10
+ let sizeBytes = 0;
11
+ const visit = (directory) => {
12
+ for (const name of readdirSync(directory).sort()) {
13
+ const entry = path.join(directory, name);
14
+ const info = lstatSync(entry);
15
+ const type = info.isSymbolicLink() ? "link" : info.isDirectory() ? "directory" : "file";
16
+ const bytes = type === "link" ? Buffer.from(readlinkSync(entry)) : type === "file" ? readFileSync(entry) : Buffer.alloc(0);
17
+ digest.update(`${path.relative(file, entry).split(path.sep).join("/")}\0${type}\0${bytes.length}\0`).update(bytes);
18
+ sizeBytes += bytes.length;
19
+ if (type === "directory") visit(entry);
20
+ }
21
+ };
22
+ visit(file);
23
+ return { sha256: digest.digest("hex"), sizeBytes, kind: "bundle-tree-v1" };
24
+ }
25
+ const bytes = readFileSync(file);
26
+ return { sha256: createHash("sha256").update(bytes).digest("hex"), sizeBytes: statSync(file).size };
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.141",
3
+ "version": "0.2.143",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
package/release.mjs CHANGED
@@ -29,6 +29,9 @@ import { materializeHardeningEvidence } from "./hardening-evidence.mjs";
29
29
  import { signingReceiptPath } from "./signing-receipts.mjs";
30
30
  import { notarizeAppBundle, validateNotarizationReceipt } from "./macos-notarization.mjs";
31
31
  import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
32
+ import { fileEvidence } from "./macos-file-evidence.mjs";
33
+ import { expandMacSigningEntries } from "./mac-bundle-signing.mjs";
34
+ import { buildStapledSwiftMacDmg, swiftMacDmgConfigError, swiftMacDmgPlan } from "./swift-mac-dmg.mjs";
32
35
  import { runConfiguredChecks } from "./release-checks.mjs";
33
36
  import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
34
37
  import { DEFAULT_STORAGE_THRESHOLD_BYTES, findInstallerCopyScripts, findLargeRepositorySubdirectories } from "./installer-storage-doctor.mjs";
@@ -157,6 +160,8 @@ if (opts.platform === "mac") {
157
160
  if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} mac must declare at least one sign.prePackageFiles`);
158
161
  if (!target.notarize?.file) fail(`${config.app ?? "app"} mac must declare notarize.file`);
159
162
  if (target.notarize.appBeforePackage && !target.notarize.app) fail(`${config.app ?? "app"} mac notarize.appBeforePackage requires notarize.app`);
163
+ const swiftDmgError = swiftMacDmgConfigError({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: config.app ?? "app" });
164
+ if (swiftDmgError) fail(swiftDmgError);
160
165
  }
161
166
  if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
162
167
  /**
@@ -329,6 +334,14 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
329
334
  }
330
335
  }
331
336
 
337
+ if (opts.platform === "mac" && runMacPhase("package")) {
338
+ // SwiftPM + appBeforePackage: the .app is stapled by now, so right-release packs it into the DMG itself.
339
+ const swiftDmg = swiftMacDmgPlan({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: config.app ?? path.basename(root), resolve: (p) => resolveReleaseArtifactPath(root, p) });
340
+ if (swiftDmg) {
341
+ if (opts.dryRun) console.log(`dry-run: build DMG ${swiftDmg.out} from stapled app ${swiftDmg.app}, then sign it`);
342
+ else await buildStapledSwiftMacDmg(swiftDmg, { remove: (file) => rmSync(file, { force: true }), signDmg: (dmg) => signMacos([dmg], root, "dmg-signing") });
343
+ }
344
+ }
332
345
  if (command && (opts.platform !== "mac" || runMacPhase("package"))) await runCommand(command, root);
333
346
 
334
347
  for (const rel of target.artifacts ?? []) {
@@ -858,8 +871,17 @@ function windowsReceiptPath(root, phase) {
858
871
  return signingReceiptPath({ appRoot: root, target, platform: "win", phase, stateRoot: process.env.RIGHT_RELEASE_STATE_ROOT });
859
872
  }
860
873
 
861
- async function signMacos(files, root) {
862
- const receipt = macosReceiptPath(root, "signing");
874
+ async function signMacos(files, root, phase = "signing") {
875
+ const receipt = phase === "signing" ? macosReceiptPath(root, "signing") : path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts"), `macos-${phase}.json`);
876
+ // Directory entries (.app bundles): nested code first, no entitlements, then the bundle itself.
877
+ let nested = [];
878
+ try { ({ nested } = opts.dryRun ? { nested: [] } : expandMacSigningEntries(files)); } catch (error) { fail(error.message); }
879
+ if (nested.length) {
880
+ const nestedReceipt = path.resolve(path.dirname(receipt), `macos-${phase}-nested.json`);
881
+ if (existsSync(nestedReceipt)) unlinkSync(nestedReceipt);
882
+ console.log(`right-release: signing ${nested.length} nested code item(s) inside-out`);
883
+ await run("node", [SIGN_MACOS, "--receipt", nestedReceipt, ...nested], root);
884
+ }
863
885
  const args = [SIGN_MACOS];
864
886
  if (opts.dryRun) args.push("--dry-run");
865
887
  args.push("--receipt", receipt, ...files);
@@ -911,7 +933,7 @@ function verifyMacosSigningReceipt(receipt, files) {
911
933
  try { expectedIdentity = resolveMacosDeveloperIdIdentity(); } catch (error) { fail(error.message); }
912
934
  const expected = files.map((file) => path.resolve(file)).sort();
913
935
  const actual = Array.isArray(evidence.files) ? evidence.files.map((item) => path.resolve(item.file)).sort() : [];
914
- const hashes = new Map(files.map((file) => [path.resolve(file), createHash("sha256").update(readFileSync(file)).digest("hex")]));
936
+ const hashes = new Map(files.map((file) => [path.resolve(file), fileEvidence(file).sha256]));
915
937
  if (evidence.schema !== 1 || evidence.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT || !expectedIdentity || evidence.identity !== expectedIdentity
916
938
  || JSON.stringify(expected) !== JSON.stringify(actual)
917
939
  || evidence.files.some((item) => item.codesign !== "strict-valid" || item.identity !== expectedIdentity || item.timestampPresent !== true || item.after?.sha256 !== hashes.get(path.resolve(item.file)))) fail(`invalid macOS Developer ID signing receipt evidence: ${receipt}`);
@@ -16,7 +16,7 @@
16
16
  "@rightkit/app-shell": "0.3.0",
17
17
  "@rightkit/ax": "0.2.4",
18
18
  "@rightkit/doc-render": "0.1.4",
19
- "@rightkit/git": "0.2.32",
19
+ "@rightkit/git": "0.2.33",
20
20
  "@rightkit/hooks": "0.1.2",
21
21
  "@rightkit/i18n": "0.1.0",
22
22
  "@rightkit/legal": "0.3.1",
@@ -29,7 +29,7 @@
29
29
  "@rightkit/platform-ui": "0.2.0",
30
30
  "@rightkit/qa": "0.4.0",
31
31
  "@rightkit/qa-chrome": "0.1.3",
32
- "@rightkit/release": "0.2.141",
32
+ "@rightkit/release": "0.2.143",
33
33
  "@rightkit/settings-ui": "0.3.0",
34
34
  "@rightkit/shell": "0.1.0",
35
35
  "@rightkit/tauri": "0.2.0",
@@ -73,7 +73,8 @@
73
73
  "0.2.28",
74
74
  "0.2.29",
75
75
  "0.2.30",
76
- "0.2.31"
76
+ "0.2.31",
77
+ "0.2.32"
77
78
  ],
78
79
  "@rightkit/legal-ui": [
79
80
  "0.1.0",
@@ -172,7 +173,9 @@
172
173
  "0.2.137",
173
174
  "0.2.138",
174
175
  "0.2.139",
175
- "0.2.140"
176
+ "0.2.140",
177
+ "0.2.141",
178
+ "0.2.142"
176
179
  ],
177
180
  "@rightkit/qa": [
178
181
  "0.1.0",
@@ -277,7 +280,7 @@
277
280
  "rightkit-db": "0.3.2",
278
281
  "rightkit-embed": "0.3.1",
279
282
  "rightkit-framed-sidecar": "0.1.0",
280
- "rightkit-fs": "0.3.2",
283
+ "rightkit-fs": "0.3.3",
281
284
  "rightkit-fsindex": "0.1.0",
282
285
  "rightkit-http": "0.2.4",
283
286
  "rightkit-jobs": "0.2.6",
@@ -334,7 +337,7 @@
334
337
  "rightkit-db": "0.3.2",
335
338
  "rightkit-embed": "0.3.1",
336
339
  "rightkit-framed-sidecar": "0.1.0",
337
- "rightkit-fs": "0.3.2",
340
+ "rightkit-fs": "0.3.3",
338
341
  "rightkit-fsindex": "0.1.0",
339
342
  "rightkit-http": "0.2.4",
340
343
  "rightkit-jobs": "0.2.6",
@@ -371,7 +374,7 @@
371
374
  "@rightkit/app-shell": "0.3.0",
372
375
  "@rightkit/ax": "0.2.4",
373
376
  "@rightkit/doc-render": "0.1.4",
374
- "@rightkit/git": "0.2.32",
377
+ "@rightkit/git": "0.2.33",
375
378
  "@rightkit/hooks": "0.1.2",
376
379
  "@rightkit/i18n": "0.1.0",
377
380
  "@rightkit/legal": "0.3.1",
@@ -384,7 +387,7 @@
384
387
  "@rightkit/platform-ui": "0.2.0",
385
388
  "@rightkit/qa": "0.4.0",
386
389
  "@rightkit/qa-chrome": "0.1.3",
387
- "@rightkit/release": "0.2.141",
390
+ "@rightkit/release": "0.2.143",
388
391
  "@rightkit/settings-ui": "0.3.0",
389
392
  "@rightkit/shell": "0.1.0",
390
393
  "@rightkit/tauri": "0.2.0",
@@ -517,7 +520,8 @@
517
520
  "0.1.0",
518
521
  "0.2.0",
519
522
  "0.3.0",
520
- "0.3.1"
523
+ "0.3.1",
524
+ "0.3.2"
521
525
  ],
522
526
  "rightkit-service": [
523
527
  "0.1.0",
package/sign-macos.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { createHash } from "node:crypto";
3
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, statSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
3
  import path from "node:path";
5
4
  import { spawnSync } from "./background-process.mjs";
5
+ import { fileEvidence } from "./macos-file-evidence.mjs";
6
6
  import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
7
7
  import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
8
8
  import { runMacCodesignCommands } from "./macos-signing-session.mjs";
@@ -56,28 +56,6 @@ const absoluteReceipt = path.resolve(receiptPath);
56
56
  mkdirSync(path.dirname(absoluteReceipt), { recursive: true });
57
57
  writeFileSync(absoluteReceipt, `${JSON.stringify({ schema: 1, signingContract: MACOS_PORTABLE_SIGNING_CONTRACT, identity, files: evidence }, null, 2)}\n`);
58
58
 
59
- function fileEvidence(file) {
60
- if (statSync(file).isDirectory()) {
61
- const digest = createHash("sha256");
62
- let sizeBytes = 0;
63
- function visit(directory) {
64
- for (const name of readdirSync(directory).sort()) {
65
- const entry = path.join(directory, name);
66
- const info = lstatSync(entry);
67
- const type = info.isSymbolicLink() ? "link" : info.isDirectory() ? "directory" : "file";
68
- const bytes = type === "link" ? Buffer.from(readlinkSync(entry)) : type === "file" ? readFileSync(entry) : Buffer.alloc(0);
69
- digest.update(`${path.relative(file, entry).split(path.sep).join("/")}\0${type}\0${bytes.length}\0`).update(bytes);
70
- sizeBytes += bytes.length;
71
- if (type === "directory") visit(entry);
72
- }
73
- }
74
- visit(file);
75
- return { sha256: digest.digest("hex"), sizeBytes, kind: "bundle-tree-v1" };
76
- }
77
- const bytes = readFileSync(file);
78
- return { sha256: createHash("sha256").update(bytes).digest("hex"), sizeBytes: statSync(file).size };
79
- }
80
-
81
59
  function run(cmd, runArgs) {
82
60
  const result = spawnSync(cmd, runArgs, { encoding: "utf8" });
83
61
  const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
@@ -0,0 +1,52 @@
1
+ // SwiftPM macOS DMG ordering. With `notarize.appBeforePackage` the .app is notarized and stapled first;
2
+ // right-release then builds the DMG itself (dmg-build.mjs, as Tauri does) so it packs the stapled app,
3
+ // signs it, and the existing archive notarization staples the DMG.
4
+ import path from "node:path";
5
+ import { spawnSync } from "./background-process.mjs";
6
+ import { buildDmg } from "./dmg-build.mjs";
7
+
8
+ const fail = (code, message) => Object.assign(new Error(message), { code });
9
+ const DMG_HOOK = /dmg|hdiutil/i;
10
+
11
+ /** True when the prePackage hook looks like it builds a DMG (heuristic on command and arguments). */
12
+ export const prePackageBuildsDmg = (prePackage) => Boolean(prePackage?.cmd) && DMG_HOOK.test([prePackage.cmd, ...(prePackage.args ?? [])].join(" "));
13
+
14
+ /** Config error for swiftpm mac targets, or null. */
15
+ export function swiftMacDmgConfigError({ target, projectType, appName = "app" }) {
16
+ if (projectType !== "swiftpm" || !target?.notarize?.appBeforePackage) return null;
17
+ if (prePackageBuildsDmg(target.prePackage)) {
18
+ return `${appName} mac prePackage builds a DMG while notarize.appBeforePackage is on, so the DMG would pack the unstapled .app. Drop DMG creation from prePackage: right-release builds the DMG after the .app is notarized and stapled (configure targets.mac.dmg for volname/background), or set targets.mac.dmg: false and build it in targets.mac.package.`;
19
+ }
20
+ if (target.dmg !== false && !target.package?.cmd && !String(target.notarize.file ?? "").endsWith(".dmg")) {
21
+ return `${appName} mac notarize.file must be a .dmg when right-release builds the DMG (notarize.appBeforePackage with no targets.mac.package)`;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /** Plan for the automatic DMG, or null when the app config builds its own (package step, dmg: false, or no appBeforePackage). */
27
+ export function swiftMacDmgPlan({ target, projectType, appName = "app", resolve = (p) => path.resolve(p) }) {
28
+ if (projectType !== "swiftpm" || !target?.notarize?.appBeforePackage || target.dmg === false || target.package?.cmd) return null;
29
+ const d = target.dmg ?? {};
30
+ return {
31
+ app: resolve(target.notarize.app),
32
+ out: resolve(d.out ?? target.notarize.file),
33
+ options: { volname: d.volname ?? appName, background: d.background && resolve(d.background), iconSize: d.iconSize, appPosition: d.appPosition,
34
+ applicationsPosition: d.applicationsPosition, window: d.window, volumeIcon: d.volumeIcon && resolve(d.volumeIcon) },
35
+ };
36
+ }
37
+
38
+ const definedOnly = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
39
+
40
+ /**
41
+ * Build the DMG from the stapled app, then sign it. Refuses to pack an app whose notarization ticket is not
42
+ * stapled. Injectable: run (stapler), build, signDmg, log.
43
+ */
44
+ export async function buildStapledSwiftMacDmg(plan, { run = spawnSync, build = buildDmg, signDmg, log = console.log, remove = () => {} } = {}) {
45
+ const probe = run("xcrun", ["stapler", "validate", plan.app], { encoding: "utf8" });
46
+ if (probe.error || probe.status !== 0) throw fail("SWIFT_DMG_APP_NOT_STAPLED", `refusing to build the DMG: ${plan.app} is not stapled (xcrun stapler validate failed). Notarize and staple the app before packaging.`);
47
+ remove(plan.out);
48
+ log(`right-release: building macOS DMG from stapled app ${plan.app}`);
49
+ const built = build({ app: plan.app, out: plan.out, ...definedOnly(plan.options) });
50
+ if (signDmg) await signDmg(plan.out);
51
+ return built;
52
+ }