@rightkit/release 0.2.173 → 0.3.0

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.
Files changed (50) hide show
  1. package/README.md +188 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/darwin/rightkit-swap.sign.json +1 -1
  4. package/cli/right-release.mjs +34 -6
  5. package/command-spec.mjs +80 -0
  6. package/deps-cargo.mjs +55 -0
  7. package/deps.mjs +7 -0
  8. package/development-signing.mjs +135 -4
  9. package/development.mjs +12 -11
  10. package/dist-scan.mjs +102 -0
  11. package/dmg-build.mjs +53 -4
  12. package/icon-pack.mjs +117 -0
  13. package/in-place-install.mjs +32 -7
  14. package/init-command.mjs +116 -0
  15. package/install-app-windows.mjs +108 -17
  16. package/install-app.mjs +68 -11
  17. package/install-tauri-devid.mjs +106 -47
  18. package/local-publish.mjs +317 -29
  19. package/mac-bundle-signing.mjs +143 -1
  20. package/mac-signing-matrix.mjs +73 -6
  21. package/macho-approval-hash.mjs +181 -0
  22. package/macos-signing-session.mjs +12 -2
  23. package/managed-cargo.mjs +49 -1
  24. package/media-sidecar.mjs +20 -4
  25. package/mirror-root-artifact.mjs +69 -21
  26. package/npm-license-notices.mjs +159 -0
  27. package/ownership-dependency-allowlist.json +1 -0
  28. package/ownership.mjs +37 -4
  29. package/package-recipe-targets.mjs +180 -16
  30. package/package.json +1 -1
  31. package/pe-content.mjs +133 -0
  32. package/portable-development.mjs +9 -9
  33. package/publication-gate.mjs +70 -3
  34. package/publish-cargo.mjs +26 -7
  35. package/qa-build-features.mjs +7 -1
  36. package/recipe-build.mjs +346 -0
  37. package/release-dependencies-command.mjs +45 -0
  38. package/release-dependencies.mjs +60 -0
  39. package/release-switch-scan.mjs +17 -7
  40. package/release.mjs +98 -12
  41. package/rightkit-ownership.json +46 -2
  42. package/rightkit-signing-agent.mjs +10 -4
  43. package/rightkit-versions.json +11 -6
  44. package/runtime-budgets.mjs +28 -12
  45. package/stage-binary.mjs +1 -1
  46. package/stage-sidecar-command.mjs +36 -4
  47. package/stage-sidecar.mjs +926 -74
  48. package/test-command.mjs +190 -0
  49. package/toolchain-swift.mjs +239 -0
  50. package/web-build.mjs +67 -0
@@ -1,11 +1,24 @@
1
1
  #!/usr/bin/env node
2
- import { copyFileSync, lstatSync, mkdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
2
+ // Two forms:
3
+ // right-release mirror-root-artifact --file <artifact> --package-root <dir> [--name <filename>]
4
+ // right-release mirror-root-artifact --config <right-release.config.mjs> --platform mac|win [--package-root <dir>]
5
+ // The config form mirrors every entry of `targets.<platform>.mirror`, a list of `"<app-relative file>"` or
6
+ // `{ file, name?, pruneSuffix? }`. `pruneSuffix` (for example "-setup.exe") then removes the other files with that suffix
7
+ // from the managed directory, so it holds exactly the current artifact. Run it as `targets.<platform>.postSign`.
8
+ // Output goes only to RIGHT_RELEASE_ARTIFACT_DIR (outside the repository); it never writes into the repository root.
9
+ import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
3
10
  import { execFileSync } from "./background-process.mjs";
4
11
  import path from "node:path";
12
+ import { pathToFileURL } from "node:url";
5
13
 
6
14
  const args = parseArgs(process.argv.slice(2));
7
- if (!args.file || !args.packageRoot) {
8
- console.error("Usage: right-release mirror-root-artifact --file <artifact> --package-root <dir> [--name <filename>]");
15
+ const usage = "Usage: right-release mirror-root-artifact (--file <artifact> --package-root <dir> [--name <filename>] | --config <file> --platform mac|win [--package-root <dir>])";
16
+ if (!args.config && (!args.file || !args.packageRoot)) {
17
+ console.error(usage);
18
+ process.exit(2);
19
+ }
20
+ if (args.config && (!args.platform || args.file)) {
21
+ console.error(usage);
9
22
  process.exit(2);
10
23
  }
11
24
  if (!process.env.RIGHT_RELEASE_ARTIFACT_DIR) {
@@ -13,33 +26,68 @@ if (!process.env.RIGHT_RELEASE_ARTIFACT_DIR) {
13
26
  process.exit(2);
14
27
  }
15
28
 
16
- const packageRoot = realpathSync(path.resolve(args.packageRoot));
17
- const source = path.resolve(packageRoot, args.file);
18
- const name = args.name || path.basename(source);
29
+ let entries;
30
+ let rootArg = args.packageRoot;
31
+ if (args.config) {
32
+ const configPath = path.resolve(args.config);
33
+ const loaded = await import(pathToFileURL(configPath).href);
34
+ const config = loaded.default ?? loaded.config ?? loaded;
35
+ const declared = config?.targets?.[args.platform]?.mirror;
36
+ if (!Array.isArray(declared) || declared.length === 0) {
37
+ console.error(`right-release mirror-root-artifact: targets.${args.platform}.mirror declares no files in ${args.config}`);
38
+ process.exit(2);
39
+ }
40
+ entries = declared.map((item) => (typeof item === "string" ? { file: item } : item));
41
+ for (const entry of entries) {
42
+ const unknown = entry && typeof entry === "object" ? Object.keys(entry).filter((key) => !["file", "name", "pruneSuffix"].includes(key)) : ["(not an object)"];
43
+ if (typeof entry?.file !== "string" || !entry.file.trim() || unknown.length || path.isAbsolute(entry.file) || entry.file.replaceAll("\\", "/").split("/").includes("..")) {
44
+ console.error(`right-release mirror-root-artifact: each mirror entry is an app-relative file or { file, name?, pruneSuffix? }: ${JSON.stringify(entry)}`);
45
+ process.exit(2);
46
+ }
47
+ }
48
+ rootArg ??= path.dirname(configPath);
49
+ } else {
50
+ entries = [{ file: args.file, name: args.name }];
51
+ }
52
+
53
+ const packageRoot = realpathSync(path.resolve(rootArg));
19
54
  const managedOutput = path.resolve(process.env.RIGHT_RELEASE_ARTIFACT_DIR);
20
55
  const repoRoot = path.resolve(git(packageRoot, "rev-parse", "--show-toplevel"));
21
56
  if (inside(managedOutput, repoRoot)) {
22
57
  console.error(`right-release mirror-root-artifact: managed output must be outside repository: ${managedOutput}`);
23
58
  process.exit(2);
24
59
  }
25
- if (name !== path.basename(name) || !/^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/.test(name)) {
26
- console.error(`right-release mirror-root-artifact: unsafe artifact name: ${name}`);
27
- process.exit(2);
28
- }
29
- assertSafePath(source);
30
- if (!statSync(source).isFile()) {
31
- console.error(`right-release mirror-root-artifact: source is not a regular file: ${source}`);
32
- process.exit(2);
33
- }
34
- const destinations = new Set([path.join(managedOutput, name)]);
60
+ const safeName = (name) => name === path.basename(name) && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/.test(name);
35
61
 
36
- for (const destination of destinations) {
37
- if (path.resolve(destination) === source) continue;
38
- atomicCopy(source, destination);
62
+ for (const entry of entries) {
63
+ const source = path.resolve(packageRoot, entry.file);
64
+ const name = entry.name || path.basename(source);
65
+ if (!safeName(name)) {
66
+ console.error(`right-release mirror-root-artifact: unsafe artifact name: ${name}`);
67
+ process.exit(2);
68
+ }
69
+ if (entry.pruneSuffix !== undefined && (typeof entry.pruneSuffix !== "string" || !/^[A-Za-z0-9._+-]{1,40}$/.test(entry.pruneSuffix) || !name.endsWith(entry.pruneSuffix))) {
70
+ console.error(`right-release mirror-root-artifact: pruneSuffix must be a plain suffix of the artifact name: ${entry.pruneSuffix}`);
71
+ process.exit(2);
72
+ }
73
+ assertSafePath(source);
74
+ if (!statSync(source).isFile()) {
75
+ console.error(`right-release mirror-root-artifact: source is not a regular file: ${source}`);
76
+ process.exit(2);
77
+ }
78
+ const destination = path.join(managedOutput, name);
79
+ if (path.resolve(destination) !== source) atomicCopy(source, destination);
80
+ if (entry.pruneSuffix) {
81
+ for (const stale of readdirSync(managedOutput)) {
82
+ if (stale !== name && stale.endsWith(entry.pruneSuffix) && safeName(stale)) {
83
+ assertSafePath(path.join(managedOutput, stale));
84
+ rmSync(path.join(managedOutput, stale), { force: true });
85
+ }
86
+ }
87
+ }
88
+ console.log(`right-release: managed artifact -> ${destination}`);
39
89
  }
40
90
 
41
- console.log(`right-release: managed artifact -> ${[...destinations][0]}`);
42
-
43
91
  function git(cwd, ...commandArgs) {
44
92
  return execFileSync("git", commandArgs, { cwd, encoding: "utf8" }).trim();
45
93
  }
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // `right-release license-notices npm [--config <file>] [--output <file>] [--product-name <name>] [--check]`: writes the
3
+ // third-party npm license appendix (one HTML file) for an app's production dependency closure, from
4
+ // `pnpm licenses list --prod --json`, the installed packages' own LICENSE / COPYING / NOTICE files, and the lockfile hash.
5
+ //
6
+ // legal: { npmNotices: { output: "legal/THIRD_PARTY_NPM_LICENSES.html", productName: "ViewRight", exclude: ["@rightkit/"] } }
7
+ //
8
+ // `output` and `productName` may also come from `--output` / `--product-name`. `exclude` is a list of package-name prefixes left
9
+ // out (default `["@rightkit/"]`). A package that ships no license file gets the standard published text for its declared SPDX
10
+ // license (MIT or ISC), labelled as a fallback; any other license without a file is an error, never invented text.
11
+ // The page records the app version, the sha256 of `pnpm-lock.yaml` and `generated-by=right-release license-notices npm`, so the
12
+ // same inputs always produce the same bytes. `--check` writes nothing and exits 1 when the file on disk differs.
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { spawnSync } from "./background-process.mjs";
18
+ import { CommandSpecError, loadAppConfig, relativeInside } from "./command-spec.mjs";
19
+
20
+ const fail = (message) => { throw new CommandSpecError(message); };
21
+
22
+ // Generic published SPDX text, used ONLY when an installed package ships no license file of its own.
23
+ export const SPDX_FALLBACK_TEXT = {
24
+ MIT: `Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
25
+
26
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
27
+
28
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`,
29
+ ISC: `Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.`,
32
+ };
33
+
34
+ export const escapeHtml = (value) => String(value)
35
+ .replaceAll("&", "&amp;")
36
+ .replaceAll("<", "&lt;")
37
+ .replaceAll(">", "&gt;")
38
+ .replaceAll('"', "&quot;");
39
+
40
+ /** Flatten `pnpm licenses list --json` (license -> entries) into a sorted list, minus excluded name prefixes. */
41
+ export function collectNpmPackages(groups, exclude = ["@rightkit/"]) {
42
+ return Object.entries(groups)
43
+ .flatMap(([license, entries]) => entries.map((entry) => ({ ...entry, license })))
44
+ .filter((entry) => !exclude.some((prefix) => entry.name.startsWith(prefix)))
45
+ .sort((a, b) => `${a.name}@${a.versions.join(",")}`.localeCompare(`${b.name}@${b.versions.join(",")}`));
46
+ }
47
+
48
+ /**
49
+ * Render the appendix. `readNames(dir)` lists a package directory and `readText(file)` reads one file (both injectable).
50
+ * Returns `{ html, packageCount, fallbackCount }`.
51
+ */
52
+ export function renderNpmNotices({ productName, version, lockHash, packages, readNames = (dir) => readdirSync(dir), readText = (file) => readFileSync(file, "utf8") }) {
53
+ let fallbackCount = 0;
54
+ const sections = packages.map((entry) => {
55
+ const packageRoot = entry.paths[0];
56
+ const files = readNames(packageRoot).filter((name) => /^(?:license|copying|notice)(?:[._-]|$)/i.test(name)).sort();
57
+ let texts;
58
+ if (files.length) {
59
+ texts = files.map((name) => `<h3>${escapeHtml(name)}</h3><pre>${escapeHtml(readText(path.join(packageRoot, name)))}</pre>`).join("\n");
60
+ } else {
61
+ const fallback = SPDX_FALLBACK_TEXT[entry.license];
62
+ if (!fallback) {
63
+ fail(`${entry.name} ${entry.versions.join(", ")} has no packaged license/notice file and no SPDX fallback text for declared license "${entry.license}"; bundle the file or extend SPDX_FALLBACK_TEXT`);
64
+ }
65
+ fallbackCount += 1;
66
+ texts = `<h3>No packaged license file found</h3><p><em>This package's installed npm distribution does not include a LICENSE/COPYING/NOTICE file. The text below is the standard published license text for its declared SPDX license (${escapeHtml(entry.license)}), not package-specific content.</em></p><pre>${escapeHtml(fallback)}</pre>`;
67
+ }
68
+ return `<section><h2>${escapeHtml(entry.name)} ${escapeHtml(entry.versions.join(", "))}</h2><p>Declared license: ${escapeHtml(entry.license)}</p>${texts}</section>`;
69
+ });
70
+ const html = `<!doctype html>
71
+ <html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(productName)} npm third-party licenses</title></head><body>
72
+ <h1>${escapeHtml(productName)} npm third-party licenses</h1>
73
+ <p>Build provenance: ${escapeHtml(productName)} ${escapeHtml(version)}; inputs: pnpm-lock.yaml sha256=${lockHash}; generated-by=right-release license-notices npm</p>
74
+ ${sections.join("\n")}
75
+ </body></html>\n`;
76
+ return { html, packageCount: packages.length, fallbackCount };
77
+ }
78
+
79
+ /** Resolve options from config + flags. */
80
+ export function resolveNoticeOptions(config, flags = {}) {
81
+ const declared = config?.legal?.npmNotices ?? {};
82
+ if (typeof declared !== "object" || declared === null || Array.isArray(declared)) fail("legal.npmNotices must be an object");
83
+ const unknown = Object.keys(declared).filter((key) => !["output", "productName", "exclude"].includes(key));
84
+ if (unknown.length) fail(`legal.npmNotices has unknown key(s): ${unknown.join(", ")} (allowed: output, productName, exclude)`);
85
+ const output = flags.output ?? declared.output;
86
+ const productName = flags.productName ?? declared.productName;
87
+ const exclude = declared.exclude ?? ["@rightkit/"];
88
+ if (output === undefined) fail("no output file: set legal.npmNotices.output or pass --output");
89
+ relativeInside(output, "legal.npmNotices.output");
90
+ if (typeof productName !== "string" || !productName.trim()) fail("no product name: set legal.npmNotices.productName or pass --product-name");
91
+ if (!Array.isArray(exclude) || exclude.some((prefix) => typeof prefix !== "string")) fail("legal.npmNotices.exclude must be a list of name prefixes");
92
+ if ((config?.packageManager ?? "pnpm") !== "pnpm") fail(`license-notices npm supports pnpm apps only (config.packageManager is ${config.packageManager})`);
93
+ return { output, productName, exclude };
94
+ }
95
+
96
+ /** Generate (or check) the appendix for the app at `appRoot`. `spawnSyncFn` is injectable. */
97
+ export function generateNpmNotices({ appRoot, config, flags = {}, check = false, spawnSyncFn = spawnSync }) {
98
+ const { output, productName, exclude } = resolveNoticeOptions(config, flags);
99
+ const version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
100
+ const result = spawnSyncFn("pnpm", ["licenses", "list", "--prod", "--json"], {
101
+ cwd: appRoot,
102
+ encoding: "utf8",
103
+ maxBuffer: 64 * 1024 * 1024,
104
+ shell: process.platform === "win32",
105
+ });
106
+ if (result.error) throw result.error;
107
+ if (result.status !== 0) fail(result.stderr || "pnpm licenses list failed");
108
+ const packages = collectNpmPackages(JSON.parse(result.stdout), exclude);
109
+ const lockHash = createHash("sha256").update(readFileSync(path.join(appRoot, "pnpm-lock.yaml"))).digest("hex");
110
+ const { html, packageCount, fallbackCount } = renderNpmNotices({ productName, version, lockHash, packages });
111
+ const target = path.join(appRoot, output);
112
+ if (check) {
113
+ const current = existsSync(target) ? readFileSync(target, "utf8") : null;
114
+ return { target, packageCount, fallbackCount, upToDate: current === html };
115
+ }
116
+ mkdirSync(path.dirname(target), { recursive: true });
117
+ writeFileSync(target, html);
118
+ return { target, packageCount, fallbackCount, upToDate: true };
119
+ }
120
+
121
+ export function parseNoticeArgs(argv) {
122
+ const opts = { config: "right-release.config.mjs", output: undefined, productName: undefined, check: false };
123
+ const rest = argv[0] === "npm" ? argv.slice(1) : argv;
124
+ if (argv[0] !== "npm") throw new Error("expected: license-notices npm");
125
+ for (let i = 0; i < rest.length; i += 1) {
126
+ const arg = rest[i];
127
+ if (arg === "--config" || arg === "--output" || arg === "--product-name") {
128
+ const value = rest[i + 1];
129
+ if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
130
+ opts[arg === "--product-name" ? "productName" : arg.slice(2)] = value;
131
+ i += 1;
132
+ } else if (arg === "--check") opts.check = true;
133
+ else throw new Error(`unknown license-notices argument: ${arg}`);
134
+ }
135
+ return opts;
136
+ }
137
+
138
+ export async function main(argv = process.argv.slice(2)) {
139
+ const opts = parseNoticeArgs(argv);
140
+ const { config, appRoot } = await loadAppConfig(opts.config);
141
+ const outcome = generateNpmNotices({ appRoot, config, flags: { output: opts.output, productName: opts.productName }, check: opts.check });
142
+ if (opts.check) {
143
+ if (!outcome.upToDate) {
144
+ console.error(`right-release license-notices: ${outcome.target} is out of date; run right-release license-notices npm`);
145
+ return 1;
146
+ }
147
+ console.log(`${outcome.target} is up to date (${outcome.packageCount} package notices)`);
148
+ return 0;
149
+ }
150
+ console.log(`wrote ${outcome.packageCount} package notices (${outcome.fallbackCount} used an SPDX fallback text)`);
151
+ return 0;
152
+ }
153
+
154
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
155
+ main().then((code) => { process.exitCode = code; }, (error) => {
156
+ console.error(`right-release license-notices: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
157
+ process.exitCode = 1;
158
+ });
159
+ }
@@ -791,6 +791,7 @@
791
791
  "repositoryIdentity": "https://github.com/bogusyogi/postright",
792
792
  "dependencies": [
793
793
  "cargo:jiff",
794
+ "cargo:serde",
794
795
  "cargo:serde_json",
795
796
  "cargo:tauri",
796
797
  "cargo:tauri-build",
package/ownership.mjs CHANGED
@@ -156,7 +156,31 @@ function resolveLocalPath(root, sourceFile, pathValue, coverage, display, invent
156
156
  } catch { coverage.push({ path: display, status: "unscanned", reason: `${reasonPrefix}-path-unresolved${detail ? `:${detail}` : ""}` }); }
157
157
  }
158
158
 
159
- function parseCargo(parsed, file, out, workspace, root, coverage, inventory) {
159
+ // A Git dependency pinned by a full 40-hex `rev` is immutable. When its URL is third-party it is equivalent to a registry
160
+ // dependency and is not a gap (Orthic-Labs/rightkit#677). A first-party URL (Orthic-Labs, bogusyogi, or an org listed in
161
+ // ownership-repositories.json) stays an unscanned fatal gap because first-party code must be scanned. A branch, a tag, a
162
+ // short rev or no rev is always an unscanned coverage gap.
163
+ const FULL_GIT_REV = /^[0-9a-f]{40}$/i;
164
+ export const PINNED_GIT_DEPENDENCY = "pinned-git-dependency";
165
+ const FIRST_PARTY_GIT_ORGS = new Set(["github.com/orthic-labs", "github.com/bogusyogi"]);
166
+ const gitHostOrg = (url) => {
167
+ const text = String(url).trim();
168
+ const match = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@/\s]+@)?([^/:\s]+)[/:]+([^/\s]+)/i.exec(text);
169
+ return match ? `${match[1]}/${match[2]}`.toLowerCase() : null;
170
+ };
171
+ let configuredFirstParty;
172
+ function firstPartyGitOrgs() {
173
+ if (configuredFirstParty) return configuredFirstParty;
174
+ const orgs = new Set(FIRST_PARTY_GIT_ORGS);
175
+ try {
176
+ const config = JSON.parse(readFileSync(new URL("./ownership-repositories.json", import.meta.url), "utf8"));
177
+ for (const repo of config.repositories ?? []) { const key = gitHostOrg(repo.repositoryIdentity ?? ""); if (key) orgs.add(key); }
178
+ } catch { /* the built-in first-party orgs still apply */ }
179
+ return (configuredFirstParty = orgs);
180
+ }
181
+ export const isFirstPartyGitUrl = (url) => { const key = gitHostOrg(url); return key === null || firstPartyGitOrgs().has(key); };
182
+
183
+ function parseCargo(parsed, file, out, workspace, root, coverage, inventory, covered = []) {
160
184
  const pathSpec = (pathValue, sourceFile, display) => {
161
185
  resolveLocalPath(root, sourceFile, pathValue, coverage, display, inventory);
162
186
  };
@@ -176,7 +200,15 @@ function parseCargo(parsed, file, out, workspace, root, coverage, inventory) {
176
200
  if (!raw || typeof raw !== "string" && (typeof raw !== "object" || Array.isArray(raw)) || features.some((x) => typeof x !== "string") || requirement !== null && typeof requirement !== "string") throw new Error(`malformed dependency declaration: ${alias}`);
177
201
  addDependency(out, "cargo", name, file, { alias: name === alias ? null : alias, section: `${prefix}${section}`, requirement, features, defaultFeatures: spec["default-features"] ?? inherited["default-features"] ?? null, source, resolvedVersion: null, local: !!pathValue || !!git });
178
202
  if (pathValue) pathSpec(pathValue, spec.path ? file : path.posix.join(workspace.dir ?? ".", "Cargo.toml"), file);
179
- if (git) coverage.push({ path: file, status: "unscanned", reason: `git-dependency-source:${name}` });
203
+ if (git) {
204
+ const rev = spec.rev ?? inherited.rev;
205
+ const moving = [spec.branch, inherited.branch, spec.tag, inherited.tag].some((value) => value !== undefined);
206
+ if (typeof git === "string" && typeof rev === "string" && FULL_GIT_REV.test(rev) && !moving && !isFirstPartyGitUrl(git)) {
207
+ // Immutable third-party source, not a gap: kept out of `coverage` (every coverage row blocks the gate) and reported on
208
+ // `deps.covered`. `scanned: false` records that the source is pinned, not examined.
209
+ covered.push({ path: file, status: PINNED_GIT_DEPENDENCY, reason: `git-dependency-source:${name}`, fingerprint: `${git}@${rev.toLowerCase()}`, scanned: false });
210
+ } else coverage.push({ path: file, status: "unscanned", reason: `git-dependency-source:${name}` });
211
+ }
180
212
  }
181
213
  for (const [target, value] of Object.entries(table.target ?? {})) addTable(value, `${prefix}target.'${target}'.`);
182
214
  };
@@ -375,7 +407,7 @@ function resolveDependencyVersions(appRoot, inventory, deps, coverage, contentDi
375
407
  }
376
408
 
377
409
  function dependenciesFromInventory(appRoot, inventory) {
378
- const deps = [], coverage = [], contentDigests = [];
410
+ const deps = [], coverage = [], contentDigests = [], covered = [];
379
411
  // Workspace inheritance is loaded before member manifests.
380
412
  const manifestNames = new Set(["Cargo.toml", "package.json", "Package.swift"]);
381
413
  const manifests = inventory.files.filter((x) => manifestNames.has(path.posix.basename(x.rel)));
@@ -419,7 +451,7 @@ function dependenciesFromInventory(appRoot, inventory) {
419
451
  if (base === "Cargo.toml") {
420
452
  const dir = path.posix.dirname(rel);
421
453
  const workspace = workspaces.filter((x) => x.dir === "." || dir === x.dir || dir.startsWith(`${x.dir}/`)).sort((a, b) => b.dir.length - a.dir.length)[0] ?? { dir: ".", deps: {} };
422
- try { parseCargo(parseToml(text), rel, deps, workspace, appRoot, coverage, inventory); }
454
+ try { parseCargo(parseToml(text), rel, deps, workspace, appRoot, coverage, inventory, covered); }
423
455
  catch { coverage.push({ path: rel, status: "unscanned", reason: "invalid-cargo-toml" }); }
424
456
  }
425
457
  else if (base === "package.json") {
@@ -453,6 +485,7 @@ function dependenciesFromInventory(appRoot, inventory) {
453
485
  resolveDependencyVersions(appRoot, inventory, deps, coverage, contentDigests);
454
486
  Object.defineProperty(deps, "coverage", { value: coverage, enumerable: false });
455
487
  Object.defineProperty(deps, "contentDigests", { value: contentDigests, enumerable: false });
488
+ Object.defineProperty(deps, "covered", { value: covered, enumerable: false });
456
489
  Object.defineProperty(deps, "manifests", { value: files, enumerable: false });
457
490
  return deps;
458
491
  }
@@ -4,7 +4,10 @@
4
4
  // marker patch before signing, raw-EXE and installer Authenticode, the NSIS embedding verification, Developer ID
5
5
  // signing of the .app and its nested code, app notarization before packing, the signed DMG and its notarization.
6
6
  // The standalone recipes (win-package-recipe.mjs, mac-package-recipe.mjs) hold the same steps for direct callers.
7
+ import { existsSync } from "node:fs";
7
8
  import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
8
11
  import { validateWinPackage, WinPackageConfigError } from "./win-package-recipe.mjs";
9
12
 
10
13
  export class PackageRecipeTargetError extends Error {
@@ -21,40 +24,166 @@ const withoutRecipe = (target) => {
21
24
  return rest;
22
25
  };
23
26
  const QUIET_TAURI_ENV = { TAURI_SIGNING_PRIVATE_KEY: "", TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" };
27
+ const RECIPE_BUILD = fileURLToPath(new URL("./recipe-build.mjs", import.meta.url));
28
+ const CREATE_MAC_UPDATER = fileURLToPath(new URL("./create-mac-updater.mjs", import.meta.url));
29
+ const PROFILE = /^[a-z][a-z0-9_-]*$/;
30
+
31
+ function relativeInside(value, label) {
32
+ if (typeof value !== "string" || !value.trim()) fail(`${label} must be a non-empty relative path`);
33
+ const normalized = value.replaceAll("\\", "/");
34
+ if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").includes("..")) fail(`${label} must be relative and stay inside the app: ${value}`);
35
+ return normalized;
36
+ }
37
+
38
+ /** `prepare` / `postBuild`: a list of { cmd, args?, cwd? } argv commands (no shell). */
39
+ function commandList(value, label) {
40
+ if (!Array.isArray(value)) fail(`${label} must be an array of { cmd, args?, cwd? }`);
41
+ return value.map((item, index) => {
42
+ const where = `${label}[${index}]`;
43
+ if (!isObject(item)) fail(`${where} must be an object`);
44
+ const unknown = Object.keys(item).filter((key) => !["cmd", "args", "cwd"].includes(key));
45
+ if (unknown.length) fail(`${where} has unknown key(s): ${unknown.join(", ")} (allowed: cmd, args, cwd)`);
46
+ if (typeof item.cmd !== "string" || !item.cmd.trim()) fail(`${where}.cmd must be a non-empty string`);
47
+ if (item.args !== undefined && (!Array.isArray(item.args) || item.args.some((arg) => typeof arg !== "string"))) fail(`${where}.args must be a list of strings`);
48
+ return { cmd: item.cmd, args: [...(item.args ?? [])], ...(item.cwd === undefined ? {} : { cwd: relativeInside(item.cwd, `${where}.cwd`) }) };
49
+ });
50
+ }
51
+
52
+ /** The keys shared by winPackage and macPackage: profile, tauriConfig, prepare (and postBuild where allowed). */
53
+ function buildExtras(spec, label, { postBuild }) {
54
+ if (spec.profile !== undefined && (typeof spec.profile !== "string" || !PROFILE.test(spec.profile))) fail(`${label}.profile must be a Cargo profile name`);
55
+ const tauriConfig = spec.tauriConfig === undefined ? undefined : relativeInside(spec.tauriConfig, `${label}.tauriConfig`);
56
+ if (tauriConfig && !/\.json$/i.test(tauriConfig)) fail(`${label}.tauriConfig must be a .json file`);
57
+ return {
58
+ profile: spec.profile,
59
+ tauriConfig,
60
+ prepare: spec.prepare === undefined ? [] : commandList(spec.prepare, `${label}.prepare`),
61
+ postBuild: !postBuild || spec.postBuild === undefined ? [] : commandList(spec.postBuild, `${label}.postBuild`),
62
+ };
63
+ }
64
+
65
+ /** Tauri v2 passes Cargo flags after `--`; the profile also decides where Tauri looks for the built binary. */
66
+ const profileArgs = (extras) => (extras.profile ? ["--", "--profile", extras.profile] : []);
67
+ /** The build command also takes `--jobs N` (Cargo parallelism), after the same `--`. */
68
+ const buildCargoArgs = (extras, jobs) => {
69
+ const flags = [...(extras.profile ? ["--profile", extras.profile] : []), ...(jobs ? ["--jobs", String(jobs)] : [])];
70
+ return flags.length ? ["--", ...flags] : [];
71
+ };
72
+ const configArgs = (extras, appRoot, cwd) => (extras.tauriConfig ? ["--config", path.relative(path.resolve(appRoot, cwd), path.resolve(appRoot, extras.tauriConfig)).split(path.sep).join("/")] : []);
73
+
74
+ /**
75
+ * release.mjs runs ONE prePackage command: node recipe-build.mjs runs the built-in cleanup, prepare, the Tauri build,
76
+ * the artifact stability poll and postBuild in order. `cleanup` and `stability` are recipe-build plan sections.
77
+ */
78
+ function recipeBuildCommand({ extras, build, env, cleanup, stability, timeoutMs }) {
79
+ const steps = [
80
+ ...extras.prepare.map((step) => ({ phase: "prepare", ...step })),
81
+ { phase: "build", cmd: build.cmd, args: build.args, cwd: build.cwd },
82
+ ...extras.postBuild.map((step) => ({ phase: "postBuild", ...step })),
83
+ ];
84
+ const plan = { version: 1, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}) };
85
+ return { cmd: process.execPath, args: [RECIPE_BUILD, "--plan", JSON.stringify(plan)], env, ...(timeoutMs ? { timeoutMs } : {}) };
86
+ }
87
+
88
+ const DEFAULT_WIN_TIMEOUT_MS = 20 * 60 * 1000;
89
+ const positiveInt = (value) => Number.isInteger(value) && value > 0;
90
+
91
+ /**
92
+ * Managed Cargo target roots. Artifact paths in a target are declared (and default) under `<cargo dir>/target/...`.
93
+ * With `cargoTargetRoot` (true: <tauriCwd>/src-tauri/Cargo.toml; or an app-relative Cargo.toml path) or CARGO_TARGET_DIR set
94
+ * (default manifest), `cargo metadata`'s target_directory is resolved and every declared path under the default
95
+ * `<cargo dir>/target/` is moved to the real directory. release.mjs needs app-relative paths with no `..`, so a target
96
+ * directory outside the app fails closed (use RIGHT_RELEASE_ARTIFACT_DIR for copies, see the docs).
97
+ */
98
+ function targetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label }) {
99
+ const explicit = spec.cargoTargetRoot;
100
+ if (explicit !== undefined && explicit !== true && explicit !== false && typeof explicit !== "string") fail(`${label}.cargoTargetRoot must be true or an app-relative Cargo.toml path`);
101
+ const envDir = typeof env.CARGO_TARGET_DIR === "string" && env.CARGO_TARGET_DIR.trim() !== "";
102
+ if (explicit === false || (!explicit && !envDir)) return (declared) => declared;
103
+ const manifestRel = typeof explicit === "string" ? relativeInside(explicit, `${label}.cargoTargetRoot`) : path.posix.join(cwd === "." ? "" : cwd, "src-tauri", "Cargo.toml");
104
+ const manifest = path.resolve(appRoot, manifestRel);
105
+ if (typeof explicit !== "string" && explicit !== true && !existsSync(manifest)) return (declared) => declared;
106
+ let targetDir;
107
+ try { targetDir = resolveTargetRoot(manifest); } catch (error) { fail(`${label}: cannot resolve the Cargo target directory (${error.message})`); }
108
+ const prefix = path.posix.join(path.posix.dirname(manifestRel).replace(/^\.$/, ""), "target");
109
+ return (declared) => {
110
+ const normalized = String(declared).replaceAll("\\", "/");
111
+ if (!normalized.startsWith(`${prefix}/`)) return declared;
112
+ const moved = path.resolve(targetDir, normalized.slice(prefix.length + 1));
113
+ const relative = path.relative(appRoot, moved).split(path.sep).join("/");
114
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) || /^[A-Za-z]:/.test(relative)) {
115
+ fail(`${label}: the Cargo target directory ${targetDir} is outside the app, so ${declared} cannot be addressed by an app-relative path; point CARGO_TARGET_DIR inside the app or declare the artifact paths yourself`);
116
+ }
117
+ return relative;
118
+ };
119
+ }
120
+
121
+ function winPrePackage({ extras, cwd, appRoot, triple, jobs, timeoutMs, cleanup, stability }) {
122
+ const args = ["exec", "tauri", "build", "--no-bundle", "--target", triple, ...configArgs(extras, appRoot, cwd), ...buildCargoArgs(extras, jobs)];
123
+ return recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup, stability, timeoutMs });
124
+ }
24
125
 
25
126
  /** `targets.win.winPackage` → prePackage (`tauri build --no-bundle`) and package (`tauri bundle --bundles nsis`). */
26
- export function expandWinPackageTarget(target, { appRoot, config = {} }) {
127
+ export function expandWinPackageTarget(target, { appRoot, config = {}, env = process.env, resolveTargetRoot = cargoResolveTargetRoot }) {
27
128
  if (!target?.winPackage) return withoutRecipe(target);
28
129
  if (target.prePackage !== undefined) fail("targets.win.winPackage replaces prePackage; remove the prePackage script");
29
130
  if (target.winPackage.sidecars !== undefined) fail("targets.win.winPackage.sidecars is not supported in a right-release target: declare sidecars through externalBin or mediaSidecar, which release.mjs signs with the raw EXE");
131
+ if (!isObject(target.winPackage)) fail("targets.win.winPackage must be an object");
132
+ const { profile: _p, tauriConfig: _t, prepare: _pr, postBuild, jobs, timeoutMs, cleanStale, cargoTargetRoot: _c, ...coreSpec } = target.winPackage;
133
+ if (postBuild !== undefined) fail("targets.win.winPackage.postBuild is not supported: release.mjs signs the raw EXE between the build and the bundle; use prepare");
134
+ if (jobs !== undefined && !positiveInt(jobs)) fail("targets.win.winPackage.jobs must be a positive integer (Cargo --jobs)");
135
+ if (timeoutMs !== undefined && !positiveInt(timeoutMs)) fail("targets.win.winPackage.timeoutMs must be a positive integer (milliseconds)");
136
+ if (cleanStale !== undefined && typeof cleanStale !== "boolean") fail("targets.win.winPackage.cleanStale must be a boolean");
137
+ const extras = buildExtras(target.winPackage, "targets.win.winPackage", { postBuild: false });
30
138
  let plan;
31
- try { plan = validateWinPackage({ appRoot, config, target }); } catch (error) {
139
+ try { plan = validateWinPackage({ appRoot, config, target: { ...target, winPackage: coreSpec } }); } catch (error) {
32
140
  if (error instanceof WinPackageConfigError) fail(`targets.win.winPackage: ${(error.issues ?? [error.message]).join("; ")}`);
33
141
  throw error;
34
142
  }
35
143
  if (target.signingContract !== undefined && target.signingContract !== "windows-raw-exe-authenticode-before-nsis-v1") fail(`targets.win.winPackage builds an NSIS installer; signingContract must be windows-raw-exe-authenticode-before-nsis-v1, got ${target.signingContract}`);
36
144
  const cwd = rel(plan.appRoot, plan.tauriCwd) || ".";
37
145
  const override = { bundle: { createUpdaterArtifacts: false, windows: { nsis: { installMode: plan.perMachine ? "perMachine" : "currentUser" } } } };
146
+ const mapPath = targetRootMapper({ spec: target.winPackage, appRoot: plan.appRoot, cwd, env, resolveTargetRoot, label: "targets.win.winPackage" });
147
+ const rawExe = mapPath(rel(plan.appRoot, plan.rawExe));
148
+ const installerFile = mapPath(rel(plan.appRoot, plan.installerFile));
149
+ // Stale outputs are removed before the build, and the raw EXE is polled until it stops changing after it.
150
+ const cleanup = cleanStale === false ? undefined : { remove: [rawExe, installerFile] };
151
+ const timeout = timeoutMs ?? DEFAULT_WIN_TIMEOUT_MS;
38
152
  // `recipe` is set only here: a hand-written one is dropped so it can never select a recipe path.
39
153
  const { winPackage, recipe: _ignoredWin, ...rest } = target;
40
154
  return {
41
155
  ...rest,
42
156
  signingContract: "windows-raw-exe-authenticode-before-nsis-v1",
43
- prePackage: { cmd: "pnpm", args: ["exec", "tauri", "build", "--no-bundle", "--target", plan.triple], cwd, env: QUIET_TAURI_ENV },
44
- package: { cmd: "pnpm", args: ["exec", "tauri", "bundle", "--bundles", "nsis", "--target", plan.triple, "--config", JSON.stringify(override)], cwd, env: QUIET_TAURI_ENV },
45
- sign: { ...(target.sign ?? {}), prePackageFiles: [rel(plan.appRoot, plan.rawExe)], files: [rel(plan.appRoot, plan.installerFile)] },
157
+ prePackage: winPrePackage({ extras, cwd, appRoot: plan.appRoot, triple: plan.triple, jobs, timeoutMs: timeout, cleanup, stability: { files: [rawExe] } }),
158
+ package: { cmd: "pnpm", args: ["exec", "tauri", "bundle", "--bundles", "nsis", "--target", plan.triple, ...configArgs(extras, plan.appRoot, cwd), "--config", JSON.stringify(override), ...profileArgs(extras)], cwd, env: QUIET_TAURI_ENV, timeoutMs: timeout },
159
+ sign: { ...(target.sign ?? {}), prePackageFiles: [rawExe], files: [installerFile] },
46
160
  recipe: { kind: "winPackage", productName: plan.productName, updater: plan.updater },
47
161
  };
48
162
  }
49
163
 
50
- const MAC_KEYS = new Set(["universal", "tauriCwd", "features"]);
164
+ const MAC_KEYS = new Set(["universal", "triple", "tauriCwd", "features", "profile", "tauriConfig", "prepare", "postBuild", "cleanup", "cargoTargetRoot", "updater"]);
165
+ const MAC_TRIPLES = ["aarch64-apple-darwin", "x86_64-apple-darwin"];
166
+ const MAC_CLEANUP_KEYS = new Set(["staleApps", "staleDmgVolumes", "lsclean"]);
167
+
168
+ function macCleanupSpec(value) {
169
+ if (value === undefined) return {};
170
+ if (!isObject(value)) fail("targets.mac.macPackage.cleanup must be an object");
171
+ const unknown = Object.keys(value).filter((key) => !MAC_CLEANUP_KEYS.has(key));
172
+ if (unknown.length) fail(`targets.mac.macPackage.cleanup has unknown key(s): ${unknown.join(", ")} (allowed: ${[...MAC_CLEANUP_KEYS].join(", ")})`);
173
+ for (const key of MAC_CLEANUP_KEYS) if (value[key] !== undefined && typeof value[key] !== "boolean") fail(`targets.mac.macPackage.cleanup.${key} must be a boolean`);
174
+ return value;
175
+ }
51
176
 
52
177
  /**
53
178
  * `targets.mac.macPackage` → prePackage (`tauri build --bundles app`, universal by default), the .app as the
54
179
  * Developer ID signing set, app notarization before packing, and right-release's own signed DMG from the stapled app.
55
180
  * The app path and DMG path are `notarize.app` and `notarize.file`; DMG branding is `targets.mac.dmg`.
181
+ * Architecture: `triple` (an explicit arch triple) beats `universal`; `universal: false` means this host's arch.
182
+ * `cleanup` runs built-in steps in recipe-build before the build (stale DMG volumes, stale .app bundles, lsregister).
183
+ * `updater: true | "<app-relative .tar.gz>"` emits the Tauri updater tarball and its signature from the signed, stapled .app:
184
+ * it prepends a `create-mac-updater` command to `postSign` (default output `<App>.app.tar.gz` beside the app's working root).
56
185
  */
57
- export function expandMacPackageTarget(target, { appRoot }) {
186
+ export function expandMacPackageTarget(target, { appRoot, env = process.env, resolveTargetRoot = cargoResolveTargetRoot }) {
58
187
  if (!target?.macPackage) return withoutRecipe(target);
59
188
  const spec = target.macPackage;
60
189
  if (!isObject(spec)) fail("targets.mac.macPackage must be an object");
@@ -66,30 +195,65 @@ export function expandMacPackageTarget(target, { appRoot }) {
66
195
  if (target.dmg === false) fail("targets.mac.macPackage always builds a DMG; remove dmg: false");
67
196
  if (spec.universal !== undefined && typeof spec.universal !== "boolean") fail("targets.mac.macPackage.universal must be a boolean");
68
197
  if (spec.features !== undefined && (!Array.isArray(spec.features) || spec.features.some((f) => typeof f !== "string" || !/^[A-Za-z0-9_,:-]+$/.test(f)))) fail("targets.mac.macPackage.features must be Cargo feature names");
198
+ if (spec.triple !== undefined && !MAC_TRIPLES.includes(spec.triple)) fail(`targets.mac.macPackage.triple must be one of ${MAC_TRIPLES.join(", ")}`);
69
199
  const cwd = spec.tauriCwd ?? ".";
70
200
  if (typeof cwd !== "string" || path.isAbsolute(cwd) || cwd.split(/[\\/]/).includes("..")) fail("targets.mac.macPackage.tauriCwd must be a relative path inside the app");
201
+ const cleanupSpec = macCleanupSpec(spec.cleanup);
202
+ if (spec.updater !== undefined && spec.updater !== true && spec.updater !== false && typeof spec.updater !== "string") fail("targets.mac.macPackage.updater must be true or an app-relative .tar.gz path");
203
+ if (typeof spec.updater === "string" && !/\.tar\.gz$/i.test(relativeInside(spec.updater, "targets.mac.macPackage.updater"))) fail("targets.mac.macPackage.updater must end in .tar.gz");
71
204
  // Tauri's universal-apple-darwin target builds both slices and lipos them itself.
72
- const triple = spec.universal === false ? (process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin") : "universal-apple-darwin";
205
+ const triple = spec.triple ?? (spec.universal === false ? (process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin") : "universal-apple-darwin");
206
+ if (spec.triple !== undefined) {
207
+ const wrong = [...MAC_TRIPLES, "universal-apple-darwin"].find((other) => other !== triple && target.notarize.app.replaceAll("\\", "/").split("/").includes(other));
208
+ if (wrong) fail(`targets.mac.macPackage.triple is ${triple} but notarize.app is under ${wrong}; the built .app lives in the ${triple} directory`);
209
+ }
73
210
  const override = { bundle: { createUpdaterArtifacts: false, macOS: { signingIdentity: null } } };
74
- const args = ["exec", "tauri", "build", "--target", triple, "--bundles", "app", "--no-sign", "--config", JSON.stringify(override)];
211
+ const extras = buildExtras(spec, "targets.mac.macPackage", { postBuild: true });
212
+ const args = ["exec", "tauri", "build", "--target", triple, "--bundles", "app", "--no-sign", ...configArgs(extras, appRoot, cwd), "--config", JSON.stringify(override)];
75
213
  if (spec.features?.length) args.push("--features", spec.features.join(","));
76
- const prePackageFiles = target.sign?.prePackageFiles ?? [target.notarize.app];
77
- if (!prePackageFiles.some((entry) => (typeof entry === "string" ? entry : entry?.file) === target.notarize.app)) fail("targets.mac.macPackage: sign.prePackageFiles must include notarize.app");
214
+ args.push(...profileArgs(extras));
215
+ const mapPath = targetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label: "targets.mac.macPackage" });
216
+ const app = mapPath(target.notarize.app);
217
+ const entryFile = (entry) => (typeof entry === "string" ? entry : entry?.file);
218
+ const declaredFiles = target.sign?.prePackageFiles ?? [target.notarize.app];
219
+ if (!declaredFiles.some((entry) => entryFile(entry) === target.notarize.app)) fail("targets.mac.macPackage: sign.prePackageFiles must include notarize.app");
220
+ const prePackageFiles = declaredFiles.map((entry) => (typeof entry === "string" ? mapPath(entry) : { ...entry, file: mapPath(entry.file) }));
221
+ // Built-in cleanup before the build: the bundle dir is where a stale .app lives; DMG volumes are named after the product.
222
+ const appPosix = app.replaceAll("\\", "/");
223
+ const productName = path.posix.basename(appPosix, ".app");
224
+ const volumes = [...new Set([productName, ...(isObject(target.dmg) && typeof target.dmg.volname === "string" ? [target.dmg.volname] : [])])];
225
+ const cleanup = cleanupSpec.staleApps || cleanupSpec.staleDmgVolumes || cleanupSpec.lsclean
226
+ ? {
227
+ ...(cleanupSpec.staleDmgVolumes ? { dmgVolumes: volumes } : {}),
228
+ ...(cleanupSpec.staleApps || cleanupSpec.lsclean ? { apps: { dir: path.posix.dirname(appPosix), remove: cleanupSpec.staleApps === true, unregister: cleanupSpec.lsclean === true } } : {}),
229
+ }
230
+ : undefined;
231
+ const wrapped = extras.prepare.length || extras.postBuild.length || cleanup;
78
232
  const { macPackage, recipe: _ignoredMac, ...rest } = target;
233
+ // The updater tarball is made from the signed .app, so it runs in postSign, ahead of any postSign the app declares.
234
+ const updaterOutput = spec.updater === true ? `${productName}.app.tar.gz` : typeof spec.updater === "string" ? relativeInside(spec.updater, "targets.mac.macPackage.updater") : undefined;
235
+ const postSign = updaterOutput === undefined ? {} : {
236
+ postSign: [
237
+ { cmd: process.execPath, args: [CREATE_MAC_UPDATER, "--app", app, "--output", updaterOutput] },
238
+ ...(target.postSign === undefined ? [] : Array.isArray(target.postSign) ? target.postSign : [target.postSign]),
239
+ ],
240
+ };
79
241
  return {
80
242
  ...rest,
81
- prePackage: { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
243
+ ...postSign,
244
+ prePackage: wrapped ? recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup }) : { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
82
245
  sign: { ...(target.sign ?? {}), prePackageFiles },
83
- notarize: { ...target.notarize, appBeforePackage: true },
246
+ notarize: { ...target.notarize, app, appBeforePackage: true },
84
247
  dmg: isObject(target.dmg) ? target.dmg : {},
85
248
  recipe: { kind: "macPackage", universal: triple === "universal-apple-darwin" },
86
249
  };
87
250
  }
88
251
 
89
252
  /** Expand whichever declarative package key the target carries. */
90
- export function expandPackageRecipeTarget(target, { platform, appRoot, config }) {
91
- if (platform === "win") return expandWinPackageTarget(target, { appRoot, config });
92
- if (platform === "mac") return expandMacPackageTarget(target, { appRoot, config });
253
+ export function expandPackageRecipeTarget(target, { platform, appRoot, config, env, resolveTargetRoot }) {
254
+ const injected = { ...(env ? { env } : {}), ...(resolveTargetRoot ? { resolveTargetRoot } : {}) };
255
+ if (platform === "win") return expandWinPackageTarget(target, { appRoot, config, ...injected });
256
+ if (platform === "mac") return expandMacPackageTarget(target, { appRoot, config, ...injected });
93
257
  if (target?.winPackage || target?.macPackage) fail(`winPackage / macPackage are not valid on ${platform}`);
94
258
  return target;
95
259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.173",
3
+ "version": "0.3.0",
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",