@rightkit/release 0.2.140 → 0.2.142

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
@@ -20,10 +20,10 @@ export class InstallAppError extends Error {
20
20
  }
21
21
 
22
22
  const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest"]);
23
- const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json", "--dev"]);
23
+ const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json", "--dev", "--recover"]);
24
24
 
25
25
  export function parseArgs(argv) {
26
- const opts = { dmg: null, zip: null, app: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false };
26
+ const opts = { dmg: null, zip: null, app: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
27
27
  for (let i = 0; i < argv.length; i += 1) {
28
28
  const flag = argv[i];
29
29
  if (VALUE_FLAGS.has(flag)) {
@@ -39,6 +39,7 @@ export function parseArgs(argv) {
39
39
  } else if (BOOL_FLAGS.has(flag)) {
40
40
  if (flag === "--json") opts.json = true;
41
41
  else if (flag === "--dev") opts.dev = true;
42
+ else if (flag === "--recover") opts.recover = true;
42
43
  else opts.relaunch = flag === "--relaunch";
43
44
  } else {
44
45
  throw new InstallAppError("usage", `unknown argument: ${flag}`);
@@ -46,6 +47,14 @@ export function parseArgs(argv) {
46
47
  }
47
48
  if (argv.includes("--relaunch") && argv.includes("--no-relaunch")) throw new InstallAppError("usage", "--relaunch and --no-relaunch are mutually exclusive");
48
49
  const inputs = [opts.dmg, opts.zip, opts.app].filter((v) => v !== null).length;
50
+ if (opts.recover) {
51
+ if (inputs > 0 || opts.dev || opts.relaunch !== null || opts.bundleId !== null) throw new InstallAppError("usage", "--recover only accepts --dest, --app-name, and --json");
52
+ if (opts.appName !== null && (!/^[^/\0]+\.app$/.test(opts.appName) || opts.appName === ".app" || opts.appName.includes(".."))) {
53
+ throw new InstallAppError("usage", "--app-name must be a bare bundle name ending in .app");
54
+ }
55
+ if (!path.isAbsolute(opts.dest)) opts.dest = path.resolve(opts.dest);
56
+ return opts;
57
+ }
49
58
  if (inputs === 0) throw new InstallAppError("usage", opts.dev ? "one of --dmg <path.dmg>, --zip <path.zip>, or --app <path.app> is required" : "--dmg <path.dmg> is required");
50
59
  if (inputs > 1) throw new InstallAppError("usage", "--dmg, --zip, and --app are mutually exclusive");
51
60
  if ((opts.zip !== null || opts.app !== null) && !opts.dev) throw new InstallAppError("usage", "--zip and --app require --dev (only DMGs are installable as release builds)");
@@ -164,6 +173,98 @@ function errText(error) {
164
173
  return `${error?.stderr || ""}${error?.stdout || ""}`.trim() || error?.message || String(error);
165
174
  }
166
175
 
176
+ // --- crash-safe swap: journal + recovery -----------------------------------
177
+
178
+ const JOURNAL_RE = /^\.(.+)\.rightkit-install\.json$/;
179
+
180
+ export function journalPathFor(dest, appName) {
181
+ return path.join(dest, `.${appName.replace(/\.app$/, "")}.rightkit-install.json`);
182
+ }
183
+
184
+ // Atomic write: temp file, fsync, rename, then a best-effort fsync of the directory.
185
+ export function writeJournal(fsImpl, file, data) {
186
+ const tmp = `${file}.tmp`;
187
+ const fd = fsImpl.openSync(tmp, "w");
188
+ try { fsImpl.writeSync(fd, `${JSON.stringify(data)}\n`); fsImpl.fsyncSync(fd); }
189
+ finally { fsImpl.closeSync(fd); }
190
+ fsImpl.renameSync(tmp, file);
191
+ try {
192
+ const dfd = fsImpl.openSync(path.dirname(file), "r");
193
+ try { fsImpl.fsyncSync(dfd); } finally { fsImpl.closeSync(dfd); }
194
+ } catch { /* directory fsync is best effort */ }
195
+ }
196
+
197
+ const defaultIsAlive = (pid) => {
198
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
199
+ };
200
+
201
+ async function trashBundle(run, fsImpl, home, now, bundle, appName) {
202
+ const trashDir = path.join(home, ".Trash");
203
+ fsImpl.mkdirSync(trashDir, { recursive: true });
204
+ const trashed = path.join(trashDir, trashName(appName, now()));
205
+ await moveAside(run, fsImpl, bundle, trashed);
206
+ return trashed;
207
+ }
208
+
209
+ // 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 }) {
211
+ let names = [];
212
+ try { names = fsImpl.readdirSync(dest).filter((n) => JOURNAL_RE.test(n)); } catch { return []; }
213
+ if (appName) names = names.filter((n) => n === path.basename(journalPathFor(dest, appName)));
214
+ const results = [];
215
+ for (const name of names.sort()) {
216
+ const file = path.join(dest, name);
217
+ let j;
218
+ try { j = JSON.parse(fsImpl.readFileSync(file, "utf8")); } catch {
219
+ event("recovery-journal-unreadable", { journal: file });
220
+ try { fsImpl.rmSync(file, { force: true }); } catch { /* best effort */ }
221
+ continue;
222
+ }
223
+ if (j.pid && j.pid !== selfPid && isAlive(j.pid)) { event("recovery-skipped", { journal: file, pid: j.pid, reason: "install still running" }); continue; }
224
+ const has = (p) => Boolean(p) && fsImpl.existsSync(p);
225
+ const rm = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
226
+ let action;
227
+ let keepJournal = false;
228
+ try {
229
+ if (!has(j.installed)) {
230
+ if (has(j.backup)) { fsImpl.renameSync(j.backup, j.installed); action = "restored-backup"; }
231
+ else action = "cleared";
232
+ } else if (has(j.backup)) {
233
+ let ok = j.phase === "verified";
234
+ if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
235
+ if (ok) {
236
+ try { await trashBundle(run, fsImpl, home, now, j.backup, path.basename(j.installed)); action = "rolled-forward"; }
237
+ catch { action = "rolled-forward-trash-pending"; keepJournal = true; }
238
+ } else {
239
+ rm(j.installed);
240
+ fsImpl.renameSync(j.backup, j.installed);
241
+ action = "rolled-back";
242
+ }
243
+ } else action = "cleared";
244
+ } catch (error) {
245
+ event("recovery-failed", { journal: file, reason: errText(error) });
246
+ continue; // leave the journal for the next run
247
+ }
248
+ rm(j.staged);
249
+ if (j.mount && has(j.mount)) {
250
+ const out = await detachWithRetry({ mount: j.mount, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
251
+ if (out.ok) rm(j.mountRoot);
252
+ } else rm(j.mountRoot);
253
+ if (!keepJournal) rm(file);
254
+ event("recovered", { journal: file, action, installed: j.installed });
255
+ results.push({ journal: file, action, installed: j.installed });
256
+ }
257
+ return results;
258
+ }
259
+
260
+ 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;
262
+ 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 });
264
+ emit({ event: "recover-done", dest: opts.dest, recovered: results.length });
265
+ return { ok: true, recovered: results };
266
+ }
267
+
167
268
  export async function installApp(opts, deps = {}) {
168
269
  const {
169
270
  execFile = realExecFile,
@@ -175,6 +276,10 @@ export async function installApp(opts, deps = {}) {
175
276
  kill = (pid, sig) => process.kill(pid, sig),
176
277
  emit = () => {},
177
278
  tmpdir = os.tmpdir(),
279
+ pid = process.pid,
280
+ isAlive = defaultIsAlive,
281
+ signalTarget = process,
282
+ exit = (code) => process.exit(code),
178
283
  } = deps;
179
284
  const dev = opts.dev === true;
180
285
  const event = (name, data = {}) => emit({ event: name, ...(dev ? { dev: true } : {}), ...data });
@@ -184,6 +289,40 @@ export async function installApp(opts, deps = {}) {
184
289
  let mounted = false;
185
290
  let mountPoint = null;
186
291
  let failedStage = "start";
292
+ let swap = null; // set once the journal exists; tracks which renames happened
293
+
294
+ const removeQuiet = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
295
+ // Undo an unfinished swap using renames only. Sync on purpose so a signal handler can run it.
296
+ const rollbackSwap = () => {
297
+ if (!swap) return;
298
+ const s = swap;
299
+ swap = null;
300
+ try {
301
+ if (!s.final) {
302
+ if (s.stagedIn) removeQuiet(s.installed);
303
+ if (s.backedUp && !fsImpl.existsSync(s.installed)) fsImpl.renameSync(s.backup, s.installed);
304
+ removeQuiet(s.staged);
305
+ removeQuiet(s.journal);
306
+ } else {
307
+ removeQuiet(s.staged);
308
+ }
309
+ } catch { /* recovery on the next run finishes the job */ }
310
+ };
311
+ let signaled = false;
312
+ const onSignal = (sig) => {
313
+ if (signaled) return;
314
+ signaled = true;
315
+ (async () => {
316
+ try {
317
+ rollbackSwap();
318
+ event("interrupted", { signal: sig });
319
+ await detach();
320
+ cleanupExtract();
321
+ } catch { /* exiting anyway */ }
322
+ exit(128 + (os.constants.signals[sig] || 0));
323
+ })();
324
+ };
325
+ const handlers = { SIGINT: () => onSignal("SIGINT"), SIGTERM: () => onSignal("SIGTERM") };
187
326
 
188
327
  const detach = async () => {
189
328
  if (!mounted) return;
@@ -199,6 +338,7 @@ export async function installApp(opts, deps = {}) {
199
338
 
200
339
  try {
201
340
  if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
341
+ for (const [sig, fn] of Object.entries(handlers)) signalTarget.on(sig, fn);
202
342
  const inputPath = opts.dmg ?? opts.zip ?? opts.app;
203
343
  const inputKind = opts.dmg ? "dmg" : opts.zip ? "zip" : "app";
204
344
  event("start", { [inputKind]: inputPath, dest: opts.dest });
@@ -206,6 +346,8 @@ export async function installApp(opts, deps = {}) {
206
346
  if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
207
347
  if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
208
348
 
349
+ await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid });
350
+
209
351
  let sourceApp = null;
210
352
  const pickApp = (dir, label) => {
211
353
  const apps = fsImpl.readdirSync(dir).filter((n) => n.endsWith(".app"));
@@ -257,30 +399,39 @@ export async function installApp(opts, deps = {}) {
257
399
  const bundleId = opts.bundleId || newBundleId;
258
400
  event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled, ...(ticket ? { ticket } : {}) });
259
401
 
402
+ // Stage a verified copy on the destination volume so the swap is rename(2) only.
403
+ failedStage = "stage";
404
+ const base = appName.replace(/\.app$/, "");
405
+ const staged = path.join(opts.dest, `.${base}.rightkit-install-${pid}.app`);
406
+ const backup = hadInstalled ? path.join(opts.dest, `.${base}.rightkit-backup-${pid}.app`) : null;
407
+ const journal = journalPathFor(opts.dest, appName);
408
+ const journalData = { schema: 1, appName, pid, phase: "staging", dev, installed, backup, staged, mount: mountPoint, mountRoot };
409
+ removeQuiet(staged);
410
+ writeJournal(fsImpl, journal, journalData);
411
+ swap = { installed, backup, staged, journal, backedUp: false, stagedIn: false, final: false };
412
+ try { await run("/usr/bin/ditto", [sourceApp, staged]); }
413
+ catch (error) {
414
+ rollbackSwap();
415
+ throw new InstallAppError("ditto-failed", `ditto failed (${errText(error)}); previous app untouched`);
416
+ }
417
+ {
418
+ const { team: stagedTeam } = await verifyBundle(run, staged, { assess: !dev, staple: false });
419
+ const stagedId = await readBundleId(run, staged);
420
+ if (stagedTeam !== newTeam || stagedId !== newBundleId) throw new InstallAppError("staged-mismatch", `staged copy ${staged} does not match the verified source (team ${stagedTeam}, bundle id ${stagedId})`);
421
+ }
422
+
260
423
  // Quit running instance without UI.
261
424
  failedStage = "quit";
262
425
  const wasRunning = hadInstalled ? (await findPids(run, installed)).length > 0 : false;
263
426
  const quit = hadInstalled ? await quitApp(run, kill, sleep, installed) : { pids: [], killed: [] };
264
427
  event("quit", { wasRunning, pids: quit.pids, forced: quit.killed });
265
428
 
266
- // Replace.
429
+ // Swap: old -> backup, staged -> installed. The old bundle is never trashed before the new one is verified.
267
430
  failedStage = "replace";
268
- let trashed = null;
269
- if (hadInstalled) {
270
- const trashDir = path.join(home, ".Trash");
271
- fsImpl.mkdirSync(trashDir, { recursive: true });
272
- trashed = path.join(trashDir, trashName(appName, now()));
273
- await moveAside(run, fsImpl, installed, trashed);
274
- }
275
- try {
276
- await run("/usr/bin/ditto", [sourceApp, installed]);
277
- } catch (error) {
278
- try { fsImpl.rmSync(installed, { recursive: true, force: true }); } catch { /* ignore */ }
279
- let restored = false;
280
- if (trashed) { try { await moveAside(run, fsImpl, trashed, installed); restored = true; } catch { /* reported below */ } }
281
- throw new InstallAppError("ditto-failed", `ditto failed (${errText(error)}); ${trashed ? (restored ? "previous app restored" : `RESTORE FAILED, previous app is at ${trashed}`) : "no previous app to restore"}`);
282
- }
283
- event("replaced", { installed, previous: trashed });
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;
284
435
  // Dev builds are not notarized: clear quarantine on the NEW bundle only so Gatekeeper does not block it.
285
436
  if (dev) {
286
437
  try { await run("/usr/bin/xattr", ["-dr", "com.apple.quarantine", installed]); }
@@ -289,9 +440,21 @@ export async function installApp(opts, deps = {}) {
289
440
  await detach();
290
441
  cleanupExtract();
291
442
 
292
- // Re-verify installed copy.
443
+ // Re-verify installed copy; on failure the catch below rolls back to the backup.
293
444
  failedStage = "reverify";
294
445
  await verifyBundle(run, installed, { assess: !dev, staple: false });
446
+ writeJournal(fsImpl, journal, { ...journalData, phase: "verified", mount: null, mountRoot: null });
447
+ swap.final = true;
448
+
449
+ // Only now does the old bundle go to the Trash.
450
+ 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; }
454
+ }
455
+ if (!backup || trashed !== backup) removeQuiet(journal);
456
+ swap = null;
457
+ event("replaced", { installed, previous: trashed });
295
458
 
296
459
  // Relaunch hidden and in the background only.
297
460
  const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
@@ -304,11 +467,14 @@ export async function installApp(opts, deps = {}) {
304
467
  event("done", result);
305
468
  return result;
306
469
  } catch (error) {
470
+ rollbackSwap();
307
471
  const reason = error instanceof InstallAppError ? error.message : errText(error);
308
472
  event("failed", { stage: failedStage, code: error.code || "error", reason });
309
473
  if (!(error instanceof InstallAppError)) throw new InstallAppError("error", reason);
310
474
  throw error;
311
475
  } finally {
476
+ for (const [sig, fn] of Object.entries(handlers)) signalTarget.off(sig, fn);
477
+ rollbackSwap();
312
478
  await detach();
313
479
  cleanupExtract();
314
480
  }
@@ -325,7 +491,19 @@ async function verifyBundle(run, app, { assess, staple, dmg = null }) {
325
491
  await step("codesign-requirement", "/usr/bin/codesign", ["-dr", "-", app]);
326
492
  if (assess) await step("spctl-assess", "/usr/sbin/spctl", ["--assess", "--type", "execute", app]);
327
493
  let ticket = null;
494
+ let haveStapler = true;
328
495
  if (staple) {
496
+ // Clean macOS hosts have no Xcode/CLT, so `xcrun stapler` can be missing.
497
+ try { await run("/usr/bin/xcrun", ["--find", "stapler"]); } catch { haveStapler = false; }
498
+ }
499
+ if (staple && !haveStapler) {
500
+ if (dmg) await step("spctl-dmg", "/usr/sbin/spctl", ["-a", "-t", "open", "--context", "context:primary-signature", dmg]);
501
+ const assessed = await step("spctl-notarized", "/usr/sbin/spctl", ["-a", "-t", "exec", "-vv", app]);
502
+ if (!/Notarized Developer ID/.test(`${assessed?.stderr || ""}\n${assessed?.stdout || ""}`)) {
503
+ throw new InstallAppError("spctl-notarized", `spctl-notarized failed for ${app}: not accepted as Notarized Developer ID`);
504
+ }
505
+ ticket = "spctl-only";
506
+ } else if (staple) {
329
507
  try { await run("/usr/bin/xcrun", ["stapler", "validate", app]); ticket = "app-ticket"; }
330
508
  catch (error) {
331
509
  if (!dmg) throw new InstallAppError("stapler-validate", `stapler-validate failed for ${app}: ${errText(error)}`);
@@ -396,6 +574,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
396
574
  catch (error) {
397
575
  stderr(`right-release install-app: ${error.message}`);
398
576
  stderr("usage: right-release install-app (--dmg <path.dmg> | --dev (--zip <path.zip> | --app <path.app> | --dmg <path.dmg>)) [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
577
+ stderr(" right-release install-app --recover [--dest /Applications] [--app-name Pulse.app] [--json]");
399
578
  stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
400
579
  return 2;
401
580
  }
@@ -407,7 +586,8 @@ export async function main(argv = process.argv.slice(2), io = {}) {
407
586
  else stdout(`install-app: ${event}${Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ""}`);
408
587
  };
409
588
  try {
410
- await installApp(opts, { ...io.deps, emit });
589
+ if (opts.recover) await recoverApp(opts, { ...io.deps, emit });
590
+ else await installApp(opts, { ...io.deps, emit });
411
591
  return 0;
412
592
  } catch (error) {
413
593
  return error.code === "unsupported" ? 3 : 1;
@@ -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.140",
3
+ "version": "0.2.142",
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.140",
32
+ "@rightkit/release": "0.2.142",
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",
@@ -171,7 +172,9 @@
171
172
  "0.2.136",
172
173
  "0.2.137",
173
174
  "0.2.138",
174
- "0.2.139"
175
+ "0.2.139",
176
+ "0.2.140",
177
+ "0.2.141"
175
178
  ],
176
179
  "@rightkit/qa": [
177
180
  "0.1.0",
@@ -370,7 +373,7 @@
370
373
  "@rightkit/app-shell": "0.3.0",
371
374
  "@rightkit/ax": "0.2.4",
372
375
  "@rightkit/doc-render": "0.1.4",
373
- "@rightkit/git": "0.2.32",
376
+ "@rightkit/git": "0.2.33",
374
377
  "@rightkit/hooks": "0.1.2",
375
378
  "@rightkit/i18n": "0.1.0",
376
379
  "@rightkit/legal": "0.3.1",
@@ -383,7 +386,7 @@
383
386
  "@rightkit/platform-ui": "0.2.0",
384
387
  "@rightkit/qa": "0.4.0",
385
388
  "@rightkit/qa-chrome": "0.1.3",
386
- "@rightkit/release": "0.2.140",
389
+ "@rightkit/release": "0.2.142",
387
390
  "@rightkit/settings-ui": "0.3.0",
388
391
  "@rightkit/shell": "0.1.0",
389
392
  "@rightkit/tauri": "0.2.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
+ }