@rightkit/release 0.2.139 → 0.2.140

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"]);
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 };
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,35 @@ 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;
37
42
  else opts.relaunch = flag === "--relaunch";
38
43
  } else {
39
44
  throw new InstallAppError("usage", `unknown argument: ${flag}`);
40
45
  }
41
46
  }
42
47
  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");
48
+ const inputs = [opts.dmg, opts.zip, opts.app].filter((v) => v !== null).length;
49
+ 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
+ if (inputs > 1) throw new InstallAppError("usage", "--dmg, --zip, and --app are mutually exclusive");
51
+ 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)");
52
+ if (opts.dmg !== null && !opts.dmg.toLowerCase().endsWith(".dmg")) throw new InstallAppError("usage", "--dmg must name a .dmg file");
53
+ if (opts.zip !== null && !opts.zip.toLowerCase().endsWith(".zip")) throw new InstallAppError("usage", "--zip must name a .zip file");
54
+ if (opts.app !== null && !opts.app.replace(/\/+$/, "").toLowerCase().endsWith(".app")) throw new InstallAppError("usage", "--app must name a .app bundle");
45
55
  if (opts.appName !== null && (!/^[^/\0]+\.app$/.test(opts.appName) || opts.appName === ".app" || opts.appName.includes(".."))) {
46
56
  throw new InstallAppError("usage", "--app-name must be a bare bundle name ending in .app");
47
57
  }
48
58
  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
59
  if (!path.isAbsolute(opts.dest)) opts.dest = path.resolve(opts.dest);
50
- opts.dmg = path.resolve(opts.dmg);
60
+ if (opts.dmg !== null) opts.dmg = path.resolve(opts.dmg);
61
+ if (opts.zip !== null) opts.zip = path.resolve(opts.zip);
62
+ if (opts.app !== null) opts.app = path.resolve(opts.app);
51
63
  return opts;
52
64
  }
53
65
 
@@ -164,9 +176,11 @@ export async function installApp(opts, deps = {}) {
164
176
  emit = () => {},
165
177
  tmpdir = os.tmpdir(),
166
178
  } = deps;
167
- const event = (name, data = {}) => emit({ event: name, ...data });
179
+ const dev = opts.dev === true;
180
+ const event = (name, data = {}) => emit({ event: name, ...(dev ? { dev: true } : {}), ...data });
168
181
  const run = (cmd, args) => execFile(cmd, args);
169
182
  let mountRoot = null;
183
+ let extractRoot = null;
170
184
  let mounted = false;
171
185
  let mountPoint = null;
172
186
  let failedStage = "start";
@@ -174,51 +188,74 @@ export async function installApp(opts, deps = {}) {
174
188
  const detach = async () => {
175
189
  if (!mounted) return;
176
190
  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 */ } }
191
+ await detachWithRetry({ mount: mountPoint, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
179
192
  try { fsImpl.rmSync(mountRoot, { recursive: true, force: true }); } catch { /* best effort */ }
180
193
  };
194
+ const cleanupExtract = () => {
195
+ if (!extractRoot) return;
196
+ try { fsImpl.rmSync(extractRoot, { recursive: true, force: true }); } catch { /* best effort */ }
197
+ extractRoot = null;
198
+ };
181
199
 
182
200
  try {
183
201
  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}`);
202
+ const inputPath = opts.dmg ?? opts.zip ?? opts.app;
203
+ const inputKind = opts.dmg ? "dmg" : opts.zip ? "zip" : "app";
204
+ event("start", { [inputKind]: inputPath, dest: opts.dest });
205
+ if (dev) event("dev-build", { note: "DEV BUILD (not notarized)" });
206
+ if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
186
207
  if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
187
208
 
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;
195
-
196
209
  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`);
210
+ const pickApp = (dir, label) => {
211
+ const apps = fsImpl.readdirSync(dir).filter((n) => n.endsWith(".app"));
212
+ if (opts.appName) return apps.includes(opts.appName) ? path.join(dir, opts.appName) : null;
213
+ if (apps.length === 1) return path.join(dir, apps[0]);
214
+ if (apps.length > 1) throw new InstallAppError("ambiguous-app", `${label} contains multiple apps (${apps.join(", ")}); pass --app-name`);
215
+ return null;
216
+ };
217
+ if (opts.dmg) {
218
+ failedStage = "attach";
219
+ mountRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-"));
220
+ const attached = await run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", opts.dmg]);
221
+ const mounts = mountPointsFromAttach(attached.stdout);
222
+ if (mounts.length === 0) throw new InstallAppError("attach", "DMG attached but exposed no mount point");
223
+ mountPoint = mounts[0];
224
+ mounted = true;
225
+ for (const mp of mounts) {
226
+ sourceApp = pickApp(mp, "DMG");
227
+ if (sourceApp) break;
228
+ }
229
+ if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
230
+ } else if (opts.zip) {
231
+ failedStage = "extract";
232
+ extractRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-zip-"));
233
+ try { await run("/usr/bin/ditto", ["-x", "-k", opts.zip, extractRoot]); }
234
+ catch (error) { throw new InstallAppError("zip-extract", `ditto -x -k failed for ${opts.zip}: ${errText(error)}`); }
235
+ sourceApp = pickApp(extractRoot, "zip");
236
+ if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in zip` : "no .app found in zip");
237
+ } else {
238
+ sourceApp = opts.app.replace(/\/+$/, "");
239
+ if (opts.appName && path.basename(sourceApp) !== opts.appName) throw new InstallAppError("app-not-found", `--app is ${path.basename(sourceApp)}, not ${opts.appName}`);
202
240
  }
203
- if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
204
241
  const appName = path.basename(sourceApp);
205
242
  const installed = path.join(opts.dest, appName);
206
243
  const hadInstalled = fsImpl.existsSync(installed);
207
244
 
208
245
  // Verify the mounted app before touching anything installed.
209
246
  failedStage = "verify";
210
- const newTeam = await verifyBundle(run, sourceApp, { assess: true, staple: true });
247
+ const { team: newTeam, ticket } = await verifyBundle(run, sourceApp, { assess: !dev, staple: !dev, dmg: opts.dmg });
211
248
  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}`);
249
+ if (opts.bundleId && newBundleId !== opts.bundleId) throw new InstallAppError("bundle-id-mismatch", `${inputKind.toUpperCase()} app bundle id ${newBundleId} != --bundle-id ${opts.bundleId}`);
213
250
  let installedTeam = null;
214
251
  if (hadInstalled) {
215
252
  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}`);
253
+ if (installedBundleId !== newBundleId) throw new InstallAppError("bundle-id-mismatch", `${inputKind.toUpperCase()} app bundle id ${newBundleId} != installed bundle id ${installedBundleId}`);
217
254
  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}`);
255
+ if (installedTeam && installedTeam !== newTeam) throw new InstallAppError("team-id-changed", `refusing team change: installed ${installedTeam}, ${inputKind.toUpperCase()} ${newTeam}`);
219
256
  }
220
257
  const bundleId = opts.bundleId || newBundleId;
221
- event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled });
258
+ event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled, ...(ticket ? { ticket } : {}) });
222
259
 
223
260
  // Quit running instance without UI.
224
261
  failedStage = "quit";
@@ -244,11 +281,17 @@ export async function installApp(opts, deps = {}) {
244
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"}`);
245
282
  }
246
283
  event("replaced", { installed, previous: trashed });
284
+ // Dev builds are not notarized: clear quarantine on the NEW bundle only so Gatekeeper does not block it.
285
+ if (dev) {
286
+ try { await run("/usr/bin/xattr", ["-dr", "com.apple.quarantine", installed]); }
287
+ catch (error) { event("quarantine-clear-failed", { installed, reason: errText(error) }); }
288
+ }
247
289
  await detach();
290
+ cleanupExtract();
248
291
 
249
292
  // Re-verify installed copy.
250
293
  failedStage = "reverify";
251
- await verifyBundle(run, installed, { assess: true, staple: false });
294
+ await verifyBundle(run, installed, { assess: !dev, staple: false });
252
295
 
253
296
  // Relaunch hidden and in the background only.
254
297
  const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
@@ -257,7 +300,7 @@ export async function installApp(opts, deps = {}) {
257
300
  await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
258
301
  event("relaunched", { bundleId });
259
302
  }
260
- const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch };
303
+ const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
261
304
  event("done", result);
262
305
  return result;
263
306
  } catch (error) {
@@ -267,10 +310,11 @@ export async function installApp(opts, deps = {}) {
267
310
  throw error;
268
311
  } finally {
269
312
  await detach();
313
+ cleanupExtract();
270
314
  }
271
315
  }
272
316
 
273
- async function verifyBundle(run, app, { assess, staple }) {
317
+ async function verifyBundle(run, app, { assess, staple, dmg = null }) {
274
318
  const step = async (code, cmd, args) => {
275
319
  try { return await run(cmd, args); }
276
320
  catch (error) { throw new InstallAppError(code, `${code} failed for ${app}: ${errText(error)}`); }
@@ -280,8 +324,23 @@ async function verifyBundle(run, app, { assess, staple }) {
280
324
  if (!team) throw new InstallAppError("team-id-missing", `${app} has no Team ID (ad-hoc or unsigned)`);
281
325
  await step("codesign-requirement", "/usr/bin/codesign", ["-dr", "-", app]);
282
326
  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;
327
+ let ticket = null;
328
+ if (staple) {
329
+ try { await run("/usr/bin/xcrun", ["stapler", "validate", app]); ticket = "app-ticket"; }
330
+ catch (error) {
331
+ if (!dmg) throw new InstallAppError("stapler-validate", `stapler-validate failed for ${app}: ${errText(error)}`);
332
+ // Standard Tauri flow: the DMG carries the notarization ticket, not the app. Accept only when the
333
+ // stapled DMG validates, its primary signature is accepted, and Gatekeeper reports the app Notarized Developer ID.
334
+ await step("stapler-validate", "/usr/bin/xcrun", ["stapler", "validate", dmg]);
335
+ await step("spctl-dmg", "/usr/sbin/spctl", ["-a", "-t", "open", "--context", "context:primary-signature", dmg]);
336
+ const assessed = await step("spctl-notarized", "/usr/sbin/spctl", ["-a", "-t", "exec", "-vv", app]);
337
+ if (!/Notarized Developer ID/.test(`${assessed?.stderr || ""}\n${assessed?.stdout || ""}`)) {
338
+ throw new InstallAppError("spctl-notarized", `spctl-notarized failed for ${app}: not accepted as Notarized Developer ID`);
339
+ }
340
+ ticket = "dmg-ticket";
341
+ }
342
+ }
343
+ return { team, ticket };
285
344
  }
286
345
 
287
346
  async function teamIdOf(run, app) {
@@ -336,13 +395,15 @@ export async function main(argv = process.argv.slice(2), io = {}) {
336
395
  try { opts = parseArgs(argv); }
337
396
  catch (error) {
338
397
  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]");
398
+ 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]");
399
+ stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
340
400
  return 2;
341
401
  }
342
402
  const emit = (e) => {
343
403
  if (opts.json) { stdout(JSON.stringify({ schema: 1, ts: new Date().toISOString(), ...e })); return; }
344
404
  const { event, ...rest } = e;
345
405
  if (event === "failed") stderr(`install-app: failed at ${rest.stage}: ${rest.reason}`);
406
+ else if (event === "dev-build") stdout(`install-app: ${rest.note}`);
346
407
  else stdout(`install-app: ${event}${Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ""}`);
347
408
  };
348
409
  try {
@@ -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.140",
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.140",
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,8 @@
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"
173
175
  ],
174
176
  "@rightkit/qa": [
175
177
  "0.1.0",
@@ -276,11 +278,11 @@
276
278
  "rightkit-framed-sidecar": "0.1.0",
277
279
  "rightkit-fs": "0.3.2",
278
280
  "rightkit-fsindex": "0.1.0",
279
- "rightkit-http": "0.2.2",
281
+ "rightkit-http": "0.2.4",
280
282
  "rightkit-jobs": "0.2.6",
281
283
  "rightkit-license": "0.1.3",
282
284
  "rightkit-logs": "0.2.0",
283
- "rightkit-mcp": "0.5.1",
285
+ "rightkit-mcp": "0.5.2",
284
286
  "rightkit-media": "0.2.4",
285
287
  "rightkit-models": "0.2.7",
286
288
  "rightkit-oauth": "0.3.0",
@@ -333,11 +335,11 @@
333
335
  "rightkit-framed-sidecar": "0.1.0",
334
336
  "rightkit-fs": "0.3.2",
335
337
  "rightkit-fsindex": "0.1.0",
336
- "rightkit-http": "0.2.2",
338
+ "rightkit-http": "0.2.4",
337
339
  "rightkit-jobs": "0.2.6",
338
340
  "rightkit-license": "0.1.3",
339
341
  "rightkit-logs": "0.2.0",
340
- "rightkit-mcp": "0.5.1",
342
+ "rightkit-mcp": "0.5.2",
341
343
  "rightkit-media": "0.2.4",
342
344
  "rightkit-models": "0.2.7",
343
345
  "rightkit-oauth": "0.3.0",
@@ -368,7 +370,7 @@
368
370
  "@rightkit/app-shell": "0.3.0",
369
371
  "@rightkit/ax": "0.2.4",
370
372
  "@rightkit/doc-render": "0.1.4",
371
- "@rightkit/git": "0.2.31",
373
+ "@rightkit/git": "0.2.32",
372
374
  "@rightkit/hooks": "0.1.2",
373
375
  "@rightkit/i18n": "0.1.0",
374
376
  "@rightkit/legal": "0.3.1",
@@ -381,7 +383,7 @@
381
383
  "@rightkit/platform-ui": "0.2.0",
382
384
  "@rightkit/qa": "0.4.0",
383
385
  "@rightkit/qa-chrome": "0.1.3",
384
- "@rightkit/release": "0.2.139",
386
+ "@rightkit/release": "0.2.140",
385
387
  "@rightkit/settings-ui": "0.3.0",
386
388
  "@rightkit/shell": "0.1.0",
387
389
  "@rightkit/tauri": "0.2.0",
@@ -433,7 +435,8 @@
433
435
  "0.3.0",
434
436
  "0.3.1",
435
437
  "0.4.0",
436
- "0.5.0"
438
+ "0.5.0",
439
+ "0.5.1"
437
440
  ],
438
441
  "rightkit-media": [
439
442
  "0.2.2",
@@ -505,7 +508,9 @@
505
508
  "rightkit-http": [
506
509
  "0.1.0",
507
510
  "0.2.0",
508
- "0.2.1"
511
+ "0.2.1",
512
+ "0.2.2",
513
+ "0.2.3"
509
514
  ],
510
515
  "rightkit-fs": [
511
516
  "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")}`);