@rightkit/release 0.2.139 → 0.2.141

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
@@ -35,6 +35,8 @@ right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id d
35
35
 
36
36
  It mounts the image read-only and hidden (`-nobrowse`), verifies the app (`codesign --verify --deep --strict`, `spctl --assess`, `stapler validate`, bundle id, and Team ID against the installed copy; a Team ID change is refused) before touching the installed copy, quits the running instance with SIGTERM (15 s, then SIGKILL), moves the old bundle to `~/.Trash/<App> <timestamp>.app`, copies the new one with `ditto` (restoring the old bundle on failure), re-verifies, and relaunches only if it was running or `--relaunch` is given, using `open -g -j -b` (background, hidden, never activated). `--json` emits one lifecycle event per line (`start`, `verified`, `quit`, `replaced`, `relaunched`, `done`/`failed`). Win32 exits with "not implemented on win32". Exit codes: 0 ok, 1 failed, 2 usage, 3 unsupported platform.
37
37
 
38
+ Development builds (from `@rightkit/git` `devArtifact`, Developer ID-signed but not notarized) install with `--dev` and exactly one of `--dmg`, `--zip <path.zip>` (extracted with `ditto -x -k`), or `--app <path.app>`: `right-release install-app --dev --zip dev-mac.zip`. `--dev` still requires `codesign --verify --deep --strict`, a Team ID, no Team ID change, and a bundle-id match; it skips only `spctl` and `stapler`, removes `com.apple.quarantine` from the new bundle only, prints `DEV BUILD (not notarized)`, and sets `dev: true` on every JSON event.
39
+
38
40
  ## Entry points
39
41
 
40
42
  Bin: `right-release` (`cli/right-release.mjs`). The package has no `exports` map. The CLI is the supported surface.
@@ -66,6 +68,10 @@ Swift (SwiftPM) apps are a first-class project type. `projectType: "swiftpm"` (c
66
68
 
67
69
  macOS Swift apps use `right-release swift-package mac-app` (Info.plist from config, resources, frameworks, entitlements on the bundle only; nested code signed inside-out before the app).
68
70
 
71
+ Build a SwiftPM app with `right-release build --platform mac|win` (add `--config <path>` when the config is not `right-release.config.mjs`). Build is tier-neutral and never uploads, so it rejects `--tier` and takes no `--upload`/`--no-upload`; the old `release --tier patch --no-upload` form no longer works. Pick the tier only at upload: `right-release upload --release <sealed-id> --platform mac|win --tier patch|update`.
72
+
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
+
69
75
  ## Known issues
70
76
 
71
77
  - `smol-toml` is pinned exactly. A consumer that adds another `smol-toml` gets a second copy (npm audit 2026-10-08, release finding).
@@ -1,3 +1,4 @@
1
+ import { stripOwnLockRecords } from "./source-gate.mjs";
1
2
  import { createHash } from "node:crypto";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -193,7 +194,7 @@ export function assertCommittedCheckoutClean(root, { expectedCommit, expectedTre
193
194
  const output = runGit(path.resolve(root), ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
194
195
  encoding: "buffer", maxBuffer: MAX_TREE_BYTES, timeout: COMMAND_TIMEOUT_MS, runner, env: gitEnv(),
195
196
  });
196
- if (output.length) throw new CommittedSourceIncompleteError("committed ownership: protected release checkout has tracked or untracked changes");
197
+ if (stripOwnLockRecords(Buffer.from(output).toString("utf8")).length) throw new CommittedSourceIncompleteError("committed ownership: protected release checkout has tracked or untracked changes");
197
198
  return true;
198
199
  }
199
200
 
package/dmg-build.mjs CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { spawnSync } from "./background-process.mjs";
5
5
  import { backgroundAlias, dmgDSStore } from "./dmg-layout.mjs";
6
+ import { detachWithRetrySync } from "./hdiutil-detach.mjs";
6
7
  import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
7
8
 
8
9
  const fail = (code, message) => Object.assign(new Error(message), { code });
@@ -17,7 +18,7 @@ const pair = (value, label, positive = false) => {
17
18
  export function buildDmg({ app, out, volname, background, iconSize = 128, appPosition = [158, 231],
18
19
  applicationsPosition = [492, 231], window = [660, 400], volumeIcon } = {},
19
20
  { platform = process.platform, run = spawnSync, makeAlias = backgroundAlias,
20
- log = (event) => console.error(JSON.stringify(event)) } = {}) {
21
+ log = (event) => console.error(JSON.stringify(event)), sleep } = {}) {
21
22
  if (platform !== "darwin") throw fail("DMG_NATIVE_HOST_REQUIRED", "DMG creation requires macOS");
22
23
  if (!app || !out || !volname) throw fail("DMG_INVALID_OPTIONS", "--app, --out & --volname are required");
23
24
  app = path.resolve(app); out = path.resolve(out);
@@ -55,6 +56,12 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
55
56
  if (result.error || result.status !== 0) throw fail("DMG_COMMAND_FAILED", `${tool} ${args[0]} failed: ${result.error?.message ?? result.stderr ?? result.status}`);
56
57
  return result;
57
58
  };
59
+ // Detach flakes with "Resource busy": bounded retry with backoff, then -force as the last resort.
60
+ const detach = () => {
61
+ const outcome = detachWithRetrySync({ mount, log, ...(sleep ? { sleep } : {}),
62
+ detach: (args) => run("/usr/bin/hdiutil", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], shell: false, timeout: 300000 }) });
63
+ if (!outcome.ok) throw fail("DMG_COMMAND_FAILED", `hdiutil detach failed after ${outcome.attempts} tries and -force: ${outcome.result?.error?.message ?? outcome.result?.stderr ?? outcome.result?.status}`);
64
+ };
58
65
  log({ event: "dmg-build-started", app, out, volname });
59
66
  try {
60
67
  mkdirSync(stage); mkdirSync(mount);
@@ -78,7 +85,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
78
85
  const alias = relative ? makeAlias({ mount, volname, relative }) : undefined;
79
86
  writeFileSync(path.join(mount, ".DS_Store"), dmgDSStore({ appName: path.basename(app), iconSize,
80
87
  appPosition, applicationsPosition, window, alias }));
81
- command("/usr/bin/hdiutil", ["detach", mount]); attached = false;
88
+ detach(); attached = false;
82
89
  command("/usr/bin/hdiutil", ["convert", rw, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", compressed]);
83
90
  command("/usr/bin/hdiutil", ["verify", compressed]);
84
91
  renameSync(compressed, out);
@@ -89,7 +96,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
89
96
  throw error;
90
97
  } finally {
91
98
  if (attached || attaching) {
92
- try { command("/usr/bin/hdiutil", ["detach", "-force", mount]); attached = false; attaching = false; }
99
+ try { detach(); attached = false; attaching = false; }
93
100
  catch (error) { log({ event: "dmg-cleanup-failed", mount, work, message: error.message }); }
94
101
  }
95
102
  // Never recurse through a mount that failed to detach.
@@ -0,0 +1,42 @@
1
+ // Bounded detach for hdiutil mounts. Spotlight, Finder, and quick-look helpers can hold a freshly
2
+ // written volume for a moment, so a first `hdiutil detach` regularly fails with "Resource busy".
3
+ // Retry a fixed number of times with exponential backoff, then fall back to `-force` once.
4
+
5
+ export const DETACH_TRIES = 5;
6
+ export const DETACH_BACKOFF_MS = Object.freeze([500, 1000, 2000, 4000]);
7
+
8
+ const blockingSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
9
+ const describe = (result) => String(result?.error?.message ?? result?.stderr ?? result?.status ?? "failed").trim();
10
+
11
+ /**
12
+ * Synchronous. `detach(args)` runs `hdiutil <args>` and returns `{ status, stderr?, error? }`.
13
+ * Returns `{ ok, attempts, forced }`; throws only through the caller when `ok` is false.
14
+ */
15
+ export function detachWithRetrySync({ detach, mount, sleep = blockingSleep, log = () => {}, tries = DETACH_TRIES, backoffMs = DETACH_BACKOFF_MS }) {
16
+ let last;
17
+ for (let attempt = 1; attempt <= tries; attempt += 1) {
18
+ last = detach(["detach", mount]);
19
+ if (!last?.error && last?.status === 0) return { ok: true, attempts: attempt, forced: false, result: last };
20
+ const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
21
+ log({ event: "hdiutil-detach-retry", mount, attempt, of: tries, retryInMs: attempt < tries ? wait : 0, message: describe(last) });
22
+ if (attempt < tries) sleep(wait);
23
+ }
24
+ log({ event: "hdiutil-detach-force", mount, message: describe(last) });
25
+ const forced = detach(["detach", "-force", mount]);
26
+ return { ok: !forced?.error && forced?.status === 0, attempts: tries, forced: true, result: forced };
27
+ }
28
+
29
+ /** Async twin for promise-based runners: `detach(args)` resolves on success and rejects on failure. */
30
+ export async function detachWithRetry({ detach, mount, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), log = () => {}, tries = DETACH_TRIES, backoffMs = DETACH_BACKOFF_MS }) {
31
+ let last;
32
+ for (let attempt = 1; attempt <= tries; attempt += 1) {
33
+ try { await detach(["detach", mount]); return { ok: true, attempts: attempt, forced: false }; }
34
+ catch (error) { last = error; }
35
+ const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
36
+ log({ event: "hdiutil-detach-retry", mount, attempt, of: tries, retryInMs: attempt < tries ? wait : 0, message: String(last?.message ?? last) });
37
+ if (attempt < tries) await sleep(wait);
38
+ }
39
+ log({ event: "hdiutil-detach-force", mount, message: String(last?.message ?? last) });
40
+ try { await detach(["detach", "-force", mount]); return { ok: true, attempts: tries, forced: true }; }
41
+ catch (error) { return { ok: false, attempts: tries, forced: true, error }; }
42
+ }
package/install-app.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // Sanctioned, non-intrusive local app install from a signed DMG (macOS).
2
+ // Sanctioned, non-intrusive local app install from a signed DMG (macOS); --dev also accepts a
3
+ // Developer ID-signed, not-notarized zip or .app.
3
4
  // Never uses a shell, osascript, AppleEvents, or foreground activation.
4
5
  import { execFile as nodeExecFile } from "./background-process.mjs";
6
+ import { detachWithRetry } from "./hdiutil-detach.mjs";
5
7
  import fs from "node:fs";
6
8
  import os from "node:os";
7
9
  import path from "node:path";
@@ -17,11 +19,11 @@ export class InstallAppError extends Error {
17
19
  }
18
20
  }
19
21
 
20
- const VALUE_FLAGS = new Set(["--dmg", "--app-name", "--bundle-id", "--dest"]);
21
- const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json"]);
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", "--recover"]);
22
24
 
23
25
  export function parseArgs(argv) {
24
- const opts = { dmg: null, 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 };
25
27
  for (let i = 0; i < argv.length; i += 1) {
26
28
  const flag = argv[i];
27
29
  if (VALUE_FLAGS.has(flag)) {
@@ -29,25 +31,44 @@ export function parseArgs(argv) {
29
31
  if (value === undefined || value.startsWith("--")) throw new InstallAppError("usage", `${flag} requires a value`);
30
32
  i += 1;
31
33
  if (flag === "--dmg") opts.dmg = value;
34
+ else if (flag === "--zip") opts.zip = value;
35
+ else if (flag === "--app") opts.app = value;
32
36
  else if (flag === "--app-name") opts.appName = value;
33
37
  else if (flag === "--bundle-id") opts.bundleId = value;
34
38
  else opts.dest = value;
35
39
  } else if (BOOL_FLAGS.has(flag)) {
36
40
  if (flag === "--json") opts.json = true;
41
+ else if (flag === "--dev") opts.dev = true;
42
+ else if (flag === "--recover") opts.recover = true;
37
43
  else opts.relaunch = flag === "--relaunch";
38
44
  } else {
39
45
  throw new InstallAppError("usage", `unknown argument: ${flag}`);
40
46
  }
41
47
  }
42
48
  if (argv.includes("--relaunch") && argv.includes("--no-relaunch")) throw new InstallAppError("usage", "--relaunch and --no-relaunch are mutually exclusive");
43
- if (!opts.dmg) throw new InstallAppError("usage", "--dmg <path.dmg> is required");
44
- if (!opts.dmg.toLowerCase().endsWith(".dmg")) throw new InstallAppError("usage", "--dmg must name a .dmg file");
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
+ }
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");
59
+ if (inputs > 1) throw new InstallAppError("usage", "--dmg, --zip, and --app are mutually exclusive");
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)");
61
+ if (opts.dmg !== null && !opts.dmg.toLowerCase().endsWith(".dmg")) throw new InstallAppError("usage", "--dmg must name a .dmg file");
62
+ if (opts.zip !== null && !opts.zip.toLowerCase().endsWith(".zip")) throw new InstallAppError("usage", "--zip must name a .zip file");
63
+ if (opts.app !== null && !opts.app.replace(/\/+$/, "").toLowerCase().endsWith(".app")) throw new InstallAppError("usage", "--app must name a .app bundle");
45
64
  if (opts.appName !== null && (!/^[^/\0]+\.app$/.test(opts.appName) || opts.appName === ".app" || opts.appName.includes(".."))) {
46
65
  throw new InstallAppError("usage", "--app-name must be a bare bundle name ending in .app");
47
66
  }
48
67
  if (opts.bundleId !== null && !/^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(opts.bundleId)) throw new InstallAppError("usage", "--bundle-id is not a valid bundle identifier");
49
68
  if (!path.isAbsolute(opts.dest)) opts.dest = path.resolve(opts.dest);
50
- opts.dmg = path.resolve(opts.dmg);
69
+ if (opts.dmg !== null) opts.dmg = path.resolve(opts.dmg);
70
+ if (opts.zip !== null) opts.zip = path.resolve(opts.zip);
71
+ if (opts.app !== null) opts.app = path.resolve(opts.app);
51
72
  return opts;
52
73
  }
53
74
 
@@ -152,6 +173,98 @@ function errText(error) {
152
173
  return `${error?.stderr || ""}${error?.stdout || ""}`.trim() || error?.message || String(error);
153
174
  }
154
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
+
155
268
  export async function installApp(opts, deps = {}) {
156
269
  const {
157
270
  execFile = realExecFile,
@@ -163,62 +276,149 @@ export async function installApp(opts, deps = {}) {
163
276
  kill = (pid, sig) => process.kill(pid, sig),
164
277
  emit = () => {},
165
278
  tmpdir = os.tmpdir(),
279
+ pid = process.pid,
280
+ isAlive = defaultIsAlive,
281
+ signalTarget = process,
282
+ exit = (code) => process.exit(code),
166
283
  } = deps;
167
- const event = (name, data = {}) => emit({ event: name, ...data });
284
+ const dev = opts.dev === true;
285
+ const event = (name, data = {}) => emit({ event: name, ...(dev ? { dev: true } : {}), ...data });
168
286
  const run = (cmd, args) => execFile(cmd, args);
169
287
  let mountRoot = null;
288
+ let extractRoot = null;
170
289
  let mounted = false;
171
290
  let mountPoint = null;
172
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") };
173
326
 
174
327
  const detach = async () => {
175
328
  if (!mounted) return;
176
329
  mounted = false;
177
- try { await run("/usr/bin/hdiutil", ["detach", mountPoint]); }
178
- catch { try { await run("/usr/bin/hdiutil", ["detach", "-force", mountPoint]); } catch { /* best effort */ } }
330
+ await detachWithRetry({ mount: mountPoint, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
179
331
  try { fsImpl.rmSync(mountRoot, { recursive: true, force: true }); } catch { /* best effort */ }
180
332
  };
333
+ const cleanupExtract = () => {
334
+ if (!extractRoot) return;
335
+ try { fsImpl.rmSync(extractRoot, { recursive: true, force: true }); } catch { /* best effort */ }
336
+ extractRoot = null;
337
+ };
181
338
 
182
339
  try {
183
340
  if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
184
- event("start", { dmg: opts.dmg, dest: opts.dest });
185
- if (!fsImpl.existsSync(opts.dmg)) throw new InstallAppError("dmg-missing", `DMG not found: ${opts.dmg}`);
341
+ for (const [sig, fn] of Object.entries(handlers)) signalTarget.on(sig, fn);
342
+ const inputPath = opts.dmg ?? opts.zip ?? opts.app;
343
+ const inputKind = opts.dmg ? "dmg" : opts.zip ? "zip" : "app";
344
+ event("start", { [inputKind]: inputPath, dest: opts.dest });
345
+ if (dev) event("dev-build", { note: "DEV BUILD (not notarized)" });
346
+ if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
186
347
  if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
187
348
 
188
- failedStage = "attach";
189
- mountRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-"));
190
- const attached = await run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", opts.dmg]);
191
- const mounts = mountPointsFromAttach(attached.stdout);
192
- if (mounts.length === 0) throw new InstallAppError("attach", "DMG attached but exposed no mount point");
193
- mountPoint = mounts[0];
194
- mounted = true;
349
+ await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid });
195
350
 
196
351
  let sourceApp = null;
197
- for (const mp of mounts) {
198
- const apps = fsImpl.readdirSync(mp).filter((n) => n.endsWith(".app"));
199
- if (opts.appName) { if (apps.includes(opts.appName)) { sourceApp = path.join(mp, opts.appName); break; } }
200
- else if (apps.length === 1) { sourceApp = path.join(mp, apps[0]); break; }
201
- else if (apps.length > 1) throw new InstallAppError("ambiguous-app", `DMG contains multiple apps (${apps.join(", ")}); pass --app-name`);
352
+ const pickApp = (dir, label) => {
353
+ const apps = fsImpl.readdirSync(dir).filter((n) => n.endsWith(".app"));
354
+ if (opts.appName) return apps.includes(opts.appName) ? path.join(dir, opts.appName) : null;
355
+ if (apps.length === 1) return path.join(dir, apps[0]);
356
+ if (apps.length > 1) throw new InstallAppError("ambiguous-app", `${label} contains multiple apps (${apps.join(", ")}); pass --app-name`);
357
+ return null;
358
+ };
359
+ if (opts.dmg) {
360
+ failedStage = "attach";
361
+ mountRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-"));
362
+ const attached = await run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", opts.dmg]);
363
+ const mounts = mountPointsFromAttach(attached.stdout);
364
+ if (mounts.length === 0) throw new InstallAppError("attach", "DMG attached but exposed no mount point");
365
+ mountPoint = mounts[0];
366
+ mounted = true;
367
+ for (const mp of mounts) {
368
+ sourceApp = pickApp(mp, "DMG");
369
+ if (sourceApp) break;
370
+ }
371
+ if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
372
+ } else if (opts.zip) {
373
+ failedStage = "extract";
374
+ extractRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-zip-"));
375
+ try { await run("/usr/bin/ditto", ["-x", "-k", opts.zip, extractRoot]); }
376
+ catch (error) { throw new InstallAppError("zip-extract", `ditto -x -k failed for ${opts.zip}: ${errText(error)}`); }
377
+ sourceApp = pickApp(extractRoot, "zip");
378
+ if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in zip` : "no .app found in zip");
379
+ } else {
380
+ sourceApp = opts.app.replace(/\/+$/, "");
381
+ if (opts.appName && path.basename(sourceApp) !== opts.appName) throw new InstallAppError("app-not-found", `--app is ${path.basename(sourceApp)}, not ${opts.appName}`);
202
382
  }
203
- if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
204
383
  const appName = path.basename(sourceApp);
205
384
  const installed = path.join(opts.dest, appName);
206
385
  const hadInstalled = fsImpl.existsSync(installed);
207
386
 
208
387
  // Verify the mounted app before touching anything installed.
209
388
  failedStage = "verify";
210
- const newTeam = await verifyBundle(run, sourceApp, { assess: true, staple: true });
389
+ const { team: newTeam, ticket } = await verifyBundle(run, sourceApp, { assess: !dev, staple: !dev, dmg: opts.dmg });
211
390
  const newBundleId = await readBundleId(run, sourceApp);
212
- if (opts.bundleId && newBundleId !== opts.bundleId) throw new InstallAppError("bundle-id-mismatch", `DMG app bundle id ${newBundleId} != --bundle-id ${opts.bundleId}`);
391
+ if (opts.bundleId && newBundleId !== opts.bundleId) throw new InstallAppError("bundle-id-mismatch", `${inputKind.toUpperCase()} app bundle id ${newBundleId} != --bundle-id ${opts.bundleId}`);
213
392
  let installedTeam = null;
214
393
  if (hadInstalled) {
215
394
  const installedBundleId = await readBundleId(run, installed);
216
- if (installedBundleId !== newBundleId) throw new InstallAppError("bundle-id-mismatch", `DMG app bundle id ${newBundleId} != installed bundle id ${installedBundleId}`);
395
+ if (installedBundleId !== newBundleId) throw new InstallAppError("bundle-id-mismatch", `${inputKind.toUpperCase()} app bundle id ${newBundleId} != installed bundle id ${installedBundleId}`);
217
396
  try { installedTeam = await teamIdOf(run, installed); } catch { installedTeam = null; }
218
- if (installedTeam && installedTeam !== newTeam) throw new InstallAppError("team-id-changed", `refusing team change: installed ${installedTeam}, DMG ${newTeam}`);
397
+ if (installedTeam && installedTeam !== newTeam) throw new InstallAppError("team-id-changed", `refusing team change: installed ${installedTeam}, ${inputKind.toUpperCase()} ${newTeam}`);
219
398
  }
220
399
  const bundleId = opts.bundleId || newBundleId;
221
- event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled });
400
+ event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled, ...(ticket ? { ticket } : {}) });
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
+ }
222
422
 
223
423
  // Quit running instance without UI.
224
424
  failedStage = "quit";
@@ -226,29 +426,35 @@ export async function installApp(opts, deps = {}) {
226
426
  const quit = hadInstalled ? await quitApp(run, kill, sleep, installed) : { pids: [], killed: [] };
227
427
  event("quit", { wasRunning, pids: quit.pids, forced: quit.killed });
228
428
 
229
- // Replace.
429
+ // Swap: old -> backup, staged -> installed. The old bundle is never trashed before the new one is verified.
230
430
  failedStage = "replace";
231
- let trashed = null;
232
- if (hadInstalled) {
233
- const trashDir = path.join(home, ".Trash");
234
- fsImpl.mkdirSync(trashDir, { recursive: true });
235
- trashed = path.join(trashDir, trashName(appName, now()));
236
- await moveAside(run, fsImpl, installed, trashed);
237
- }
238
- try {
239
- await run("/usr/bin/ditto", [sourceApp, installed]);
240
- } catch (error) {
241
- try { fsImpl.rmSync(installed, { recursive: true, force: true }); } catch { /* ignore */ }
242
- let restored = false;
243
- if (trashed) { try { await moveAside(run, fsImpl, trashed, installed); restored = true; } catch { /* reported below */ } }
244
- 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"}`);
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;
435
+ // Dev builds are not notarized: clear quarantine on the NEW bundle only so Gatekeeper does not block it.
436
+ if (dev) {
437
+ try { await run("/usr/bin/xattr", ["-dr", "com.apple.quarantine", installed]); }
438
+ catch (error) { event("quarantine-clear-failed", { installed, reason: errText(error) }); }
245
439
  }
246
- event("replaced", { installed, previous: trashed });
247
440
  await detach();
441
+ cleanupExtract();
248
442
 
249
- // Re-verify installed copy.
443
+ // Re-verify installed copy; on failure the catch below rolls back to the backup.
250
444
  failedStage = "reverify";
251
- await verifyBundle(run, installed, { assess: true, staple: false });
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 });
252
458
 
253
459
  // Relaunch hidden and in the background only.
254
460
  const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
@@ -257,20 +463,24 @@ export async function installApp(opts, deps = {}) {
257
463
  await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
258
464
  event("relaunched", { bundleId });
259
465
  }
260
- const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch };
466
+ const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
261
467
  event("done", result);
262
468
  return result;
263
469
  } catch (error) {
470
+ rollbackSwap();
264
471
  const reason = error instanceof InstallAppError ? error.message : errText(error);
265
472
  event("failed", { stage: failedStage, code: error.code || "error", reason });
266
473
  if (!(error instanceof InstallAppError)) throw new InstallAppError("error", reason);
267
474
  throw error;
268
475
  } finally {
476
+ for (const [sig, fn] of Object.entries(handlers)) signalTarget.off(sig, fn);
477
+ rollbackSwap();
269
478
  await detach();
479
+ cleanupExtract();
270
480
  }
271
481
  }
272
482
 
273
- async function verifyBundle(run, app, { assess, staple }) {
483
+ async function verifyBundle(run, app, { assess, staple, dmg = null }) {
274
484
  const step = async (code, cmd, args) => {
275
485
  try { return await run(cmd, args); }
276
486
  catch (error) { throw new InstallAppError(code, `${code} failed for ${app}: ${errText(error)}`); }
@@ -280,8 +490,35 @@ async function verifyBundle(run, app, { assess, staple }) {
280
490
  if (!team) throw new InstallAppError("team-id-missing", `${app} has no Team ID (ad-hoc or unsigned)`);
281
491
  await step("codesign-requirement", "/usr/bin/codesign", ["-dr", "-", app]);
282
492
  if (assess) await step("spctl-assess", "/usr/sbin/spctl", ["--assess", "--type", "execute", app]);
283
- if (staple) await step("stapler-validate", "/usr/bin/xcrun", ["stapler", "validate", app]);
284
- return team;
493
+ let ticket = null;
494
+ let haveStapler = true;
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) {
507
+ try { await run("/usr/bin/xcrun", ["stapler", "validate", app]); ticket = "app-ticket"; }
508
+ catch (error) {
509
+ if (!dmg) throw new InstallAppError("stapler-validate", `stapler-validate failed for ${app}: ${errText(error)}`);
510
+ // Standard Tauri flow: the DMG carries the notarization ticket, not the app. Accept only when the
511
+ // stapled DMG validates, its primary signature is accepted, and Gatekeeper reports the app Notarized Developer ID.
512
+ await step("stapler-validate", "/usr/bin/xcrun", ["stapler", "validate", dmg]);
513
+ await step("spctl-dmg", "/usr/sbin/spctl", ["-a", "-t", "open", "--context", "context:primary-signature", dmg]);
514
+ const assessed = await step("spctl-notarized", "/usr/sbin/spctl", ["-a", "-t", "exec", "-vv", app]);
515
+ if (!/Notarized Developer ID/.test(`${assessed?.stderr || ""}\n${assessed?.stdout || ""}`)) {
516
+ throw new InstallAppError("spctl-notarized", `spctl-notarized failed for ${app}: not accepted as Notarized Developer ID`);
517
+ }
518
+ ticket = "dmg-ticket";
519
+ }
520
+ }
521
+ return { team, ticket };
285
522
  }
286
523
 
287
524
  async function teamIdOf(run, app) {
@@ -336,17 +573,21 @@ export async function main(argv = process.argv.slice(2), io = {}) {
336
573
  try { opts = parseArgs(argv); }
337
574
  catch (error) {
338
575
  stderr(`right-release install-app: ${error.message}`);
339
- stderr("usage: right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
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]");
578
+ stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
340
579
  return 2;
341
580
  }
342
581
  const emit = (e) => {
343
582
  if (opts.json) { stdout(JSON.stringify({ schema: 1, ts: new Date().toISOString(), ...e })); return; }
344
583
  const { event, ...rest } = e;
345
584
  if (event === "failed") stderr(`install-app: failed at ${rest.stage}: ${rest.reason}`);
585
+ else if (event === "dev-build") stdout(`install-app: ${rest.note}`);
346
586
  else stdout(`install-app: ${event}${Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ""}`);
347
587
  };
348
588
  try {
349
- await installApp(opts, { ...io.deps, emit });
589
+ if (opts.recover) await recoverApp(opts, { ...io.deps, emit });
590
+ else await installApp(opts, { ...io.deps, emit });
350
591
  return 0;
351
592
  } catch (error) {
352
593
  return error.code === "unsupported" ? 3 : 1;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "./background-process.mjs";
5
5
  import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
@@ -70,6 +70,26 @@ export function notarizeMacosArtifact({ archive, receiptPath, authArgs, profile,
70
70
  return receipt;
71
71
  }
72
72
 
73
+ /**
74
+ * Notarize and staple a signed .app BEFORE it is packaged into a DMG, so a copy dragged out of the
75
+ * DMG passes `stapler validate` offline. The app is zipped with ditto (notarytool cannot take a bare
76
+ * bundle), submitted, and its own ticket stapled; the DMG is then notarized and stapled as usual.
77
+ * The caller removes the zip after validating the receipt, which hashes it. The receipt records the submitted zip; its stapling evidence names the app bundle.
78
+ */
79
+ export function notarizeAppBundle({ app, receiptPath, profile, authArgs, zip, run = spawnSync, env = process.env, ...agentOptions }) {
80
+ app = path.resolve(app);
81
+ if (!/\.app$/i.test(app)) throw new Error(`notarize.app must name an app bundle: ${app}`);
82
+ zip = path.resolve(zip ?? `${app}.zip`);
83
+ rmSync(zip, { force: true });
84
+ checkedDitto(["-c", "-k", "--keepParent", app, zip], run);
85
+ return notarizeMacosArtifact({ archive: zip, receiptPath, profile, authArgs, app, run, env, ...agentOptions });
86
+ }
87
+
88
+ function checkedDitto(args, run) {
89
+ const result = run("/usr/bin/ditto", args, { encoding: "utf8" });
90
+ if (result.error || result.status !== 0) throw new Error(`ditto failed: ${result.error?.message ?? result.stderr ?? result.stdout ?? result.status}`);
91
+ }
92
+
73
93
  export function validateNotarizationReceipt({ receiptPath, archive, run = spawnSync }) {
74
94
  let receipt;
75
95
  try { receipt = JSON.parse(readFileSync(receiptPath, "utf8")); }
package/ownership.mjs CHANGED
@@ -234,7 +234,31 @@ function committedFileInventory(appRoot, trust, pathPrefix, committed) {
234
234
  return { files, coverage, committed: true, pathPrefix, repositoryFiles, snapshotFiles: new Map(repositoryFiles.map((file) => [file.rel, file])) };
235
235
  }
236
236
 
237
+ // Blank out Swift `//` and nested `/* */` comments outside string literals, keeping offsets.
238
+ export function stripSwiftComments(text) {
239
+ const out = text.split("");
240
+ let quote = false, depth = 0;
241
+ for (let i = 0; i < text.length; i++) {
242
+ if (depth) {
243
+ if (text.startsWith("/*", i)) { depth++; out[i] = out[i + 1] = " "; i++; continue; }
244
+ if (text.startsWith("*/", i)) { depth--; out[i] = out[i + 1] = " "; i++; continue; }
245
+ if (text[i] !== "\n") out[i] = " ";
246
+ continue;
247
+ }
248
+ if (quote) {
249
+ if (text[i] === "\\") { i++; continue; }
250
+ if (text[i] === '"' || text[i] === "\n") quote = false;
251
+ continue;
252
+ }
253
+ if (text[i] === '"') { quote = true; continue; }
254
+ if (text.startsWith("//", i)) { for (; i < text.length && text[i] !== "\n"; i++) out[i] = " "; continue; }
255
+ if (text.startsWith("/*", i)) { depth = 1; out[i] = out[i + 1] = " "; i++; }
256
+ }
257
+ return out.join("");
258
+ }
259
+
237
260
  function swiftPackageArguments(text) {
261
+ text = stripSwiftComments(text);
238
262
  const args = [];
239
263
  for (const match of text.matchAll(/\.package\s*\(/g)) {
240
264
  let at = match.index + match[0].length, start = at, depth = 1, quote = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.139",
3
+ "version": "0.2.141",
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
@@ -27,7 +27,7 @@ import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
27
27
  import { terminateProcessTree } from "./heavy-command.mjs";
28
28
  import { materializeHardeningEvidence } from "./hardening-evidence.mjs";
29
29
  import { signingReceiptPath } from "./signing-receipts.mjs";
30
- import { validateNotarizationReceipt } from "./macos-notarization.mjs";
30
+ import { notarizeAppBundle, validateNotarizationReceipt } from "./macos-notarization.mjs";
31
31
  import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
32
32
  import { runConfiguredChecks } from "./release-checks.mjs";
33
33
  import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
@@ -156,6 +156,7 @@ if (opts.platform === "mac") {
156
156
  if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} mac must declare prePackage to materialize Developer ID signing files`);
157
157
  if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} mac must declare at least one sign.prePackageFiles`);
158
158
  if (!target.notarize?.file) fail(`${config.app ?? "app"} mac must declare notarize.file`);
159
+ if (target.notarize.appBeforePackage && !target.notarize.app) fail(`${config.app ?? "app"} mac notarize.appBeforePackage requires notarize.app`);
159
160
  }
160
161
  if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
161
162
  /**
@@ -310,7 +311,11 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
310
311
  // Resolve after prePackage: with storage governance the artifact may live only in RIGHT_RELEASE_ARTIFACT_DIR.
311
312
  const signingFiles = target.sign.prePackageFiles.map((p) => resolveReleaseArtifactPath(root, p));
312
313
  for (const file of signingFiles) await mustExist(file, `missing macOS Developer ID signing artifact: ${file}`);
313
- if (runMacPhase("sign")) await signMacos(signingFiles, root);
314
+ if (runMacPhase("sign")) {
315
+ await signMacos(signingFiles, root);
316
+ // Opt-in (notarize.appBeforePackage): staple the .app itself before the package step copies it into the DMG.
317
+ if (target.notarize.appBeforePackage) await notarizeAppBeforePackage(root);
318
+ }
314
319
  } else if (nativeAssembly) {
315
320
  if (nativeAssembly.packageHook) await runCommand(nativeAssembly.packageHook, root);
316
321
  if (nativeFinalizer) {
@@ -397,7 +402,7 @@ releaseLock.release();
397
402
 
398
403
  function usage(code, message) {
399
404
  if (message) console.error(`right-release: ${message}\n`);
400
- console.error("usage: node release.mjs --tier patch|update [--config right-release.config.mjs] [--platform mac|win] [--upload|--no-upload] [--install] [--dry-run] [--doctor]");
405
+ console.error("usage: right-release build --platform mac|win [--config right-release.config.mjs] (build is tier-neutral; tier applies only to: right-release upload --release <id> --platform mac|win --tier patch|update)");
401
406
  process.exit(code);
402
407
  }
403
408
 
@@ -870,7 +875,8 @@ async function notarizeMacos(archive, root) {
870
875
  if (opts.dryRun) args.push("--dry-run");
871
876
  args.push("--receipt", receipt);
872
877
  if (target.notarize.profile) args.push("--profile", target.notarize.profile);
873
- if (target.notarize.app) args.push("--app", resolveReleaseArtifactPath(root, target.notarize.app));
878
+ // With appBeforePackage the app already carries its own stapled ticket inside the packaged DMG.
879
+ if (target.notarize.app && !target.notarize.appBeforePackage) args.push("--app", resolveReleaseArtifactPath(root, target.notarize.app));
874
880
  args.push(archive);
875
881
  if (!opts.dryRun && existsSync(receipt)) unlinkSync(receipt);
876
882
  console.log("right-release: notarizing macOS archive");
@@ -878,6 +884,21 @@ async function notarizeMacos(archive, root) {
878
884
  if (!opts.dryRun) verifyMacosNotarizationReceipt(receipt, archive);
879
885
  }
880
886
 
887
+ async function notarizeAppBeforePackage(root) {
888
+ const app = resolveReleaseArtifactPath(root, target.notarize.app);
889
+ const receipt = macosReceiptPath(root, "app-notarization");
890
+ const zip = `${app}.zip`;
891
+ console.log("right-release: notarizing and stapling macOS app before packaging");
892
+ if (opts.dryRun) { console.log(`dry-run: notarize app ${app} (zip ${zip}) receipt ${receipt}`); return; }
893
+ await mustExist(app, `missing macOS app bundle for notarize.app: ${app}`);
894
+ if (existsSync(receipt)) unlinkSync(receipt);
895
+ try {
896
+ notarizeAppBundle({ app, zip, receiptPath: receipt, profile: target.notarize.profile });
897
+ validateNotarizationReceipt({ receiptPath: receipt, archive: zip });
898
+ } catch (error) { fail(`app notarization failed: ${error.message}`); }
899
+ finally { rmSync(zip, { force: true }); }
900
+ }
901
+
881
902
  function macosReceiptPath(root, phase) {
882
903
  return signingReceiptPath({ appRoot: root, target, platform: "mac", phase, stateRoot: process.env.RIGHT_RELEASE_STATE_ROOT });
883
904
  }
@@ -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.31",
19
+ "@rightkit/git": "0.2.32",
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.139",
32
+ "@rightkit/release": "0.2.141",
33
33
  "@rightkit/settings-ui": "0.3.0",
34
34
  "@rightkit/shell": "0.1.0",
35
35
  "@rightkit/tauri": "0.2.0",
@@ -72,7 +72,8 @@
72
72
  "0.2.27",
73
73
  "0.2.28",
74
74
  "0.2.29",
75
- "0.2.30"
75
+ "0.2.30",
76
+ "0.2.31"
76
77
  ],
77
78
  "@rightkit/legal-ui": [
78
79
  "0.1.0",
@@ -169,7 +170,9 @@
169
170
  "0.2.135",
170
171
  "0.2.136",
171
172
  "0.2.137",
172
- "0.2.138"
173
+ "0.2.138",
174
+ "0.2.139",
175
+ "0.2.140"
173
176
  ],
174
177
  "@rightkit/qa": [
175
178
  "0.1.0",
@@ -276,11 +279,11 @@
276
279
  "rightkit-framed-sidecar": "0.1.0",
277
280
  "rightkit-fs": "0.3.2",
278
281
  "rightkit-fsindex": "0.1.0",
279
- "rightkit-http": "0.2.2",
282
+ "rightkit-http": "0.2.4",
280
283
  "rightkit-jobs": "0.2.6",
281
284
  "rightkit-license": "0.1.3",
282
285
  "rightkit-logs": "0.2.0",
283
- "rightkit-mcp": "0.5.1",
286
+ "rightkit-mcp": "0.5.2",
284
287
  "rightkit-media": "0.2.4",
285
288
  "rightkit-models": "0.2.7",
286
289
  "rightkit-oauth": "0.3.0",
@@ -333,11 +336,11 @@
333
336
  "rightkit-framed-sidecar": "0.1.0",
334
337
  "rightkit-fs": "0.3.2",
335
338
  "rightkit-fsindex": "0.1.0",
336
- "rightkit-http": "0.2.2",
339
+ "rightkit-http": "0.2.4",
337
340
  "rightkit-jobs": "0.2.6",
338
341
  "rightkit-license": "0.1.3",
339
342
  "rightkit-logs": "0.2.0",
340
- "rightkit-mcp": "0.5.1",
343
+ "rightkit-mcp": "0.5.2",
341
344
  "rightkit-media": "0.2.4",
342
345
  "rightkit-models": "0.2.7",
343
346
  "rightkit-oauth": "0.3.0",
@@ -368,7 +371,7 @@
368
371
  "@rightkit/app-shell": "0.3.0",
369
372
  "@rightkit/ax": "0.2.4",
370
373
  "@rightkit/doc-render": "0.1.4",
371
- "@rightkit/git": "0.2.31",
374
+ "@rightkit/git": "0.2.32",
372
375
  "@rightkit/hooks": "0.1.2",
373
376
  "@rightkit/i18n": "0.1.0",
374
377
  "@rightkit/legal": "0.3.1",
@@ -381,7 +384,7 @@
381
384
  "@rightkit/platform-ui": "0.2.0",
382
385
  "@rightkit/qa": "0.4.0",
383
386
  "@rightkit/qa-chrome": "0.1.3",
384
- "@rightkit/release": "0.2.139",
387
+ "@rightkit/release": "0.2.141",
385
388
  "@rightkit/settings-ui": "0.3.0",
386
389
  "@rightkit/shell": "0.1.0",
387
390
  "@rightkit/tauri": "0.2.0",
@@ -433,7 +436,8 @@
433
436
  "0.3.0",
434
437
  "0.3.1",
435
438
  "0.4.0",
436
- "0.5.0"
439
+ "0.5.0",
440
+ "0.5.1"
437
441
  ],
438
442
  "rightkit-media": [
439
443
  "0.2.2",
@@ -505,7 +509,9 @@
505
509
  "rightkit-http": [
506
510
  "0.1.0",
507
511
  "0.2.0",
508
- "0.2.1"
512
+ "0.2.1",
513
+ "0.2.2",
514
+ "0.2.3"
509
515
  ],
510
516
  "rightkit-fs": [
511
517
  "0.1.0",
package/source-gate.mjs CHANGED
@@ -10,8 +10,29 @@
10
10
  // real risk is shipping stale, dirty, or untested source. Distribution trust still
11
11
  // comes from OS code signing, notarization, and the Tauri updater key — untouched.
12
12
 
13
+ // right-release's own per-platform run lock (`.right-release/locks/*`, every platform) is
14
+ // written before the later clean-tree preflights run. Only untracked files directly in that lock directory are
15
+ // exempt, so repos that do not ignore `.right-release/` still pass, and nothing else in
16
+ // the vault (sealed output, receipts, state) or elsewhere is ever excused.
17
+ const OWN_LOCK_RECORD = /^\?\? (?:[^\0]*\/)?\.right-release\/locks\/[^\0/]+$/;
18
+
19
+ /** Remove right-release's own untracked lock-file records from NUL-separated porcelain v1 output. */
20
+ export function stripOwnLockRecords(status) {
21
+ const records = status.split("\0");
22
+ const kept = [];
23
+ for (let index = 0; index < records.length; index += 1) {
24
+ const record = records[index];
25
+ if (!record) continue;
26
+ if (OWN_LOCK_RECORD.test(record)) continue;
27
+ kept.push(record);
28
+ if (/^[RC]/.test(record) || /^.[RC]/.test(record)) kept.push(records[++index] ?? "");
29
+ }
30
+ return kept.length ? `${kept.join("\0")}\0` : "";
31
+ }
32
+
13
33
  export function assertCleanSource({ status, commandId = "release" }) {
14
34
  if (typeof status !== "string") throw new Error("release source status is required");
35
+ status = stripOwnLockRecords(status);
15
36
  if (status.length !== 0) {
16
37
  const files = status.split("\0").filter(Boolean).map((entry) => entry.slice(3)).slice(0, 10);
17
38
  throw new Error(`${commandId} requires a clean working tree; commit or stash first:\n${files.map((file) => `- ${file}`).join("\n")}`);