@rightkit/release 0.6.1 → 0.6.3

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
@@ -169,6 +169,18 @@ Copies each `targets.<platform>.mirror` entry (an app-relative path, or `{ file,
169
169
  never writes into the repository root. `pruneSuffix` then removes the other files with that suffix from that directory, so it
170
170
  holds only the current installer. The one-file form (`--file <path> --package-root <dir> [--name <name>]`) is unchanged.
171
171
 
172
+ ### Broker-managed Cargo target directory (`cargoTargetRoot`)
173
+
174
+ With `cargoTargetRoot: true` (or an app-relative Cargo.toml path) in `winPackage` / `macPackage`, declared artifact paths under `<cargo dir>/target/` follow `cargo metadata`'s target directory. On a RustRight-managed host that directory is owned by the broker and lies outside the app (for example `D:\.rightkit-managed\rightkit-build-control\ws\<hash>\g\<n>\t`); release.mjs needs app-relative paths, so the recipe keeps the declared paths at their default location under the app's ignored `src-tauri/target/` and plans verified copies (`managed-target-stage.mjs`, the `stage` section of the recipe-build plan):
175
+
176
+ - after `tauri build`, the raw EXE (win) or the built `.app` (mac) is copied from the real target directory to the app-relative path; every later step (sign, package, seal) sees app-relative paths unchanged;
177
+ - win: `tauri bundle` embeds the EXE it finds in the real target directory, so the signed app-relative EXE is copied back there first, and the installer is copied out afterwards (its source is deleted before the bundle step, so a leftover can never be copied);
178
+ - every copy deletes a stale destination first, writes a temp file beside the destination, verifies sha256 (a directory: a digest of every entry, symlinks kept) against the source before and after the copy, then renames; a source the build did not produce fails the step with `STAGE_SOURCE_MISSING` and no stale copy is left to package.
179
+
180
+ Safeguards on the managed Windows package step (a temporary bridge until the broker exposes artifacts into the app; a RustRight issue tracks that): the real raw EXE must equal the signed in-app EXE by sha256 right after the copy-back and again after `tauri bundle` returns (`STAGE_REAL_CHANGED`: another cargo job in the same workspace may have relinked it), and the installer is copied out only after both checks. A copy whose destination is in the broker tree never creates its parent (`STAGE_PARENT_MISSING`: the generation was rotated or evicted). The recipe re-resolves the Cargo target directory through cargo metadata before and after the step and refuses when it differs from the directory planned at expansion (`STAGE_TARGET_DIR_CHANGED`; docs/RIGHTKIT-THIN-BUILD-CONTROL.md §5 and §15).
181
+
182
+ Only a target directory under a managed root (`resolveManagedRoots`: `RIGHTKIT_DATA_ROOT`, `RIGHTKIT_MANAGED_ROOT`, `RIGHTKIT_EXTERNAL_ROOT`, `RIGHTKIT_CONTROL_ROOT`, the legacy `D:\.rightkit-managed` / `/Volumes/D/.rightkit-managed`) is staged. Any other directory outside the app still fails closed: `the Cargo target directory <dir> is outside the app, so <path> cannot be addressed by an app-relative path; point CARGO_TARGET_DIR inside the app or declare the artifact paths yourself`.
183
+
172
184
  ### macOS updater tarball from `macPackage`
173
185
 
174
186
  `targets.mac.macPackage.updater: true` (or an app-relative `.tar.gz` path) adds a `create-mac-updater` command at the front of
Binary file
package/build-release.mjs CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  writeFileSync,
19
19
  } from "node:fs";
20
20
  import path from "node:path";
21
- import { iosOwnershipScope } from "./ownership-scope.mjs";
21
+ import { preflightOwnershipScope } from "./ownership-scope.mjs";
22
22
  import { spawn, spawnSync } from "./background-process.mjs";
23
23
  import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord } from "./rightkit-registry-resolver.mjs";
24
24
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -84,7 +84,7 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativ
84
84
  version = undefined;
85
85
  }
86
86
  // An iOS target is built from its Xcode project and local packages only; the ownership scan covers just those roots.
87
- const ownershipScope = platform === "ios" ? iosOwnershipScope({ repoRoot, appRoot, target, nativeAssembly: config?.nativeAssembly }) : null;
87
+ const ownershipScope = preflightOwnershipScope({ repoRoot, appRoot, platform, target, nativeAssembly: config?.nativeAssembly });
88
88
  return collectPreflight({
89
89
  platform,
90
90
  ownershipScope,
@@ -0,0 +1,138 @@
1
+ // Broker-managed Cargo target directories (bogusyogi/rightkit#924). On a RustRight-managed host the broker owns the
2
+ // Cargo target directory outside the app, but release.mjs addresses every artifact by an app-relative path (no `..`).
3
+ // A recipe whose Cargo target directory is outside the app AND under a managed root therefore stages its declared
4
+ // artifacts: after the build they are copied from the real target directory to their default app-relative location,
5
+ // and before a later step that reads the signed raw EXE from the target directory (tauri bundle) the signed copy is
6
+ // copied back. A `fresh` source is deleted before the build and every source is polled until stable before it is
7
+ // copied, so only this build's finished output is staged. Every copy is atomic (temp file beside the destination, then rename) and verified by sha256 against its
8
+ // source; a destination in the broker tree is never created (its parent must exist) and the target directory is re-resolved
9
+ // and must be the planned one. This is a temporary bridge until the broker exposes artifacts into the app. A stale destination is deleted first, so a failed copy can never leave an old artifact to be signed or sealed.
10
+ import { createHash } from "node:crypto";
11
+ import { cpSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { managedControlCandidates } from "./managed-roots.mjs";
14
+
15
+ export class ManagedTargetStageError extends Error {
16
+ constructor(code, message, details = {}) { super(message); this.name = "ManagedTargetStageError"; this.code = code; this.details = details; }
17
+ }
18
+ const fail = (code, message, details) => { throw new ManagedTargetStageError(code, message, details); };
19
+
20
+ const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
21
+
22
+ function realOrSelf(value) {
23
+ try { return realpathSync(value); } catch { return path.resolve(value); }
24
+ }
25
+
26
+ /** The directories the RustRight broker owns: the managed data and control roots (and the legacy root). */
27
+ export function defaultManagedRoots(env = process.env) {
28
+ return managedControlCandidates({ env }).filter((root) => typeof root === "string" && path.isAbsolute(root));
29
+ }
30
+
31
+ /** True when `dir` is strictly under one of `roots` (case-insensitive on Windows). Symlinks are resolved when they exist. */
32
+ export function isUnderManagedRoot(dir, roots, { platform = process.platform } = {}) {
33
+ const fold = (value) => (platform === "win32" ? value.toLowerCase() : value);
34
+ const target = fold(realOrSelf(dir));
35
+ return roots.some((root) => {
36
+ if (typeof root !== "string" || !path.isAbsolute(root)) return false;
37
+ const relative = path.relative(fold(realOrSelf(root)), target);
38
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) && !/^[A-Za-z]:/.test(relative);
39
+ });
40
+ }
41
+
42
+ /**
43
+ * A content digest of a file, or of a directory tree (relative path, kind, file sha256, symlink target and executable bit
44
+ * of every entry, sorted). Symlinks are never followed.
45
+ */
46
+ export function contentDigest(file, fs = {}) {
47
+ const lstat = fs.lstatSync ?? lstatSync;
48
+ const readDir = fs.readdirSync ?? readdirSync;
49
+ const readFile = fs.readFileSync ?? readFileSync;
50
+ const readLink = fs.readlinkSync ?? readlinkSync;
51
+ const stat = lstat(file);
52
+ if (stat.isSymbolicLink()) return { kind: "symlink", sha256: sha256Of(`link:${readLink(file)}`) };
53
+ if (stat.isFile()) return { kind: "file", sha256: sha256Of(readFile(file)) };
54
+ if (!stat.isDirectory()) fail("STAGE_ARTIFACT_UNSUPPORTED", `${file} is neither a file nor a directory`);
55
+ const lines = [];
56
+ const walk = (dir, prefix) => {
57
+ for (const name of readDir(dir).sort()) {
58
+ const full = path.join(dir, name);
59
+ const relative = prefix ? `${prefix}/${name}` : name;
60
+ const entry = lstat(full);
61
+ if (entry.isSymbolicLink()) lines.push(`l ${relative} ${readLink(full)}`);
62
+ else if (entry.isDirectory()) { lines.push(`d ${relative}`); walk(full, relative); }
63
+ else if (entry.isFile()) lines.push(`f ${relative} ${(entry.mode & 0o111) !== 0 ? "x" : "-"} ${sha256Of(readFile(full))}`);
64
+ else fail("STAGE_ARTIFACT_UNSUPPORTED", `${full} is neither a file, a directory nor a symlink`);
65
+ }
66
+ };
67
+ walk(file, "");
68
+ return { kind: "directory", sha256: sha256Of(lines.join("\n")) };
69
+ }
70
+
71
+ /**
72
+ * Copy one artifact from `from` to `to` (both absolute): delete a stale destination, check the source exists, copy beside
73
+ * the destination, verify the copy's sha256 against the source (read before and after the copy), then rename into place.
74
+ * Returns { from, to, kind, sha256 }.
75
+ */
76
+ export function stageArtifact({ from, to }, { fs = {}, log = () => {}, requireParent = false } = {}) {
77
+ const remove = fs.rmSync ?? rmSync;
78
+ const copy = fs.cpSync ?? cpSync;
79
+ const rename = fs.renameSync ?? renameSync;
80
+ const makeDir = fs.mkdirSync ?? mkdirSync;
81
+ // A destination in the broker's tree is never created: a missing parent means the generation was rotated or evicted.
82
+ if (requireParent && !(fs.lstatSync ?? lstatSync)(path.dirname(to), { throwIfNoEntry: false })) {
83
+ fail("STAGE_PARENT_MISSING", `managed target stage: the broker directory ${path.dirname(to)} does not exist (the build-control generation was rotated or evicted); not creating it, ${from} was not copied`, { from, to });
84
+ }
85
+ // A destination left by an earlier build is never an acceptable result: remove it before anything else.
86
+ remove(to, { recursive: true, force: true });
87
+ if (!(fs.lstatSync ?? lstatSync)(from, { throwIfNoEntry: false })) {
88
+ fail("STAGE_SOURCE_MISSING", `managed target stage: the build did not produce ${from}; ${to} was removed and nothing was copied, so no stale artifact can be packaged`, { from, to });
89
+ }
90
+ const before = contentDigest(from, fs);
91
+ const temp = path.join(path.dirname(to), `.${path.basename(to)}.stage-${process.pid}-${Date.now()}.tmp`);
92
+ makeDir(path.dirname(to), { recursive: true });
93
+ remove(temp, { recursive: true, force: true });
94
+ try {
95
+ copy(from, temp, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false, errorOnExist: true });
96
+ const copied = contentDigest(temp, fs);
97
+ const after = contentDigest(from, fs);
98
+ if (copied.sha256 !== before.sha256 || after.sha256 !== before.sha256) {
99
+ fail("STAGE_DIGEST_MISMATCH", `managed target stage: sha256 of the copy of ${from} does not match its source (source ${before.sha256}, copy ${copied.sha256}, source after copy ${after.sha256}); ${to} was not created`, { from, to });
100
+ }
101
+ rename(temp, to);
102
+ } finally {
103
+ remove(temp, { recursive: true, force: true });
104
+ }
105
+ log(`recipe-build: staged ${from} -> ${to} (${before.kind} sha256 ${before.sha256})`);
106
+ return { from, to, kind: before.kind, sha256: before.sha256 };
107
+ }
108
+
109
+ /** The two files (or trees) must have the same sha256; `what` names the check in the message. */
110
+ export function verifySameContent({ app, real }, { what, fs = {} } = {}) {
111
+ const a = contentDigest(app, fs).sha256;
112
+ let b;
113
+ try { b = contentDigest(real, fs).sha256; } catch (error) { fail("STAGE_REAL_CHANGED", `managed target stage (${what}): cannot read ${real} (${error.message})`, { app, real }); }
114
+ if (a !== b) fail("STAGE_REAL_CHANGED", `managed target stage (${what}): ${real} (sha256 ${b}) is no longer the signed ${app} (sha256 ${a}); another build in the same workspace changed it, so nothing was packaged or copied out`, { app, real });
115
+ }
116
+
117
+ /** The Cargo target directory must still be the one planned: a broker generation rotation or eviction changes it. */
118
+ export function assertTargetDirUnchanged({ manifest, expected }, resolveTargetRoot, { what } = {}) {
119
+ let now;
120
+ try { now = resolveTargetRoot(manifest); } catch (error) { fail("STAGE_TARGET_DIR_CHANGED", `managed target stage (${what}): cannot re-resolve the Cargo target directory (${error.message})`, { manifest, expected }); }
121
+ const same = (x, y) => (process.platform === "win32" ? path.resolve(x).toLowerCase() === path.resolve(y).toLowerCase() : path.resolve(x) === path.resolve(y));
122
+ if (!same(now, expected)) fail("STAGE_TARGET_DIR_CHANGED", `managed target stage (${what}): the Cargo target directory is now ${now}, not the ${expected} planned for this release (broker generation rotated or evicted); refusing to copy`, { manifest, expected, now });
123
+ }
124
+
125
+ /** Remove the sources named `fresh` before a build step, so a missing output after it cannot be a leftover. */
126
+ export function removeFreshSources(entries, { fs = {}, log = () => {} } = {}) {
127
+ const remove = fs.rmSync ?? rmSync;
128
+ for (const entry of entries) {
129
+ if (!entry.fresh) continue;
130
+ log(`recipe-build: stage removes stale source ${entry.from}`);
131
+ remove(entry.from, { recursive: true, force: true });
132
+ }
133
+ }
134
+
135
+ /** Stage every entry in order; the first failure throws and nothing after it is copied. */
136
+ export function stageArtifacts(entries, options = {}) {
137
+ return entries.map((entry) => stageArtifact(entry, options));
138
+ }
@@ -87,6 +87,15 @@ export function iosOwnershipScope({ repoRoot, appRoot, target, nativeAssembly })
87
87
  return [...new Set([...checked, OWNERSHIP_SCOPE_ALWAYS])].sort();
88
88
  }
89
89
 
90
+ /**
91
+ * The one place the doctor (`releasePreflight`) and the build (`buildPreflight`) assemble the ownership scan scope, so the
92
+ * two can never disagree about what ships (rightkit#927). iOS builds from its Xcode project and local packages only; every
93
+ * other platform scans the whole repository (null).
94
+ */
95
+ export function preflightOwnershipScope({ repoRoot, appRoot, platform, target, nativeAssembly }) {
96
+ return platform === "ios" ? iosOwnershipScope({ repoRoot, appRoot, target, nativeAssembly }) : null;
97
+ }
98
+
90
99
  /** Repository-relative file or directory under any scope root (null scope = everything). */
91
100
  export function inOwnershipScope(scope, rel) {
92
101
  if (!scope) return true;
@@ -8,6 +8,7 @@ import { existsSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
11
+ import { defaultManagedRoots, isUnderManagedRoot } from "./managed-target-stage.mjs";
11
12
  import { validateWinPackage, WinPackageConfigError } from "./win-package-recipe.mjs";
12
13
  import { IOS_SIGNING_CONTRACT, IosPackageError, validateIosPackageConfig } from "./ios-package-recipe.mjs";
13
14
  import { IosUploadError, validateIosDistribution } from "./ios-upload.mjs";
@@ -77,13 +78,13 @@ const configArgs = (extras, appRoot, cwd) => (extras.tauriConfig ? ["--config",
77
78
  * release.mjs runs ONE prePackage command: node recipe-build.mjs runs the built-in cleanup, prepare, the Tauri build,
78
79
  * the artifact stability poll and postBuild in order. `cleanup` and `stability` are recipe-build plan sections.
79
80
  */
80
- function recipeBuildCommand({ extras, build, env, cleanup, stability, timeoutMs }) {
81
+ function recipeBuildCommand({ extras, build, env, cleanup, stability, stage, timeoutMs }) {
81
82
  const steps = [
82
83
  ...extras.prepare.map((step) => ({ phase: "prepare", ...step })),
83
84
  { phase: "build", cmd: build.cmd, args: build.args, cwd: build.cwd },
84
85
  ...extras.postBuild.map((step) => ({ phase: "postBuild", ...step })),
85
86
  ];
86
- const plan = { version: 1, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}) };
87
+ const plan = { version: 1, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}), ...(stage && (stage.pre?.length || stage.post?.length) ? { stage } : {}) };
87
88
  return { cmd: process.execPath, args: [RECIPE_BUILD, "--plan", JSON.stringify(plan)], env, ...(timeoutMs ? { timeoutMs } : {}) };
88
89
  }
89
90
 
@@ -95,9 +96,21 @@ const positiveInt = (value) => Number.isInteger(value) && value > 0;
95
96
  * With `cargoTargetRoot` (true: <tauriCwd>/src-tauri/Cargo.toml; or an app-relative Cargo.toml path) or CARGO_TARGET_DIR set
96
97
  * (default manifest), `cargo metadata`'s target_directory is resolved and every declared path under the default
97
98
  * `<cargo dir>/target/` is moved to the real directory. release.mjs needs app-relative paths with no `..`, so a target
98
- * directory outside the app fails closed (use RIGHT_RELEASE_ARTIFACT_DIR for copies, see the docs).
99
+ * directory outside the app fails closed (use RIGHT_RELEASE_ARTIFACT_DIR for copies, see the docs), EXCEPT a directory under
100
+ * a broker-managed root (the RustRight data root, bogusyogi/rightkit#924): the declared path then stays at its default
101
+ * app-relative location and the mapper records `{ from: <real path>, to: <declared path> }` so the caller plans a verified
102
+ * copy step (managed-target-stage.mjs). `mapPath.staged` lists the recorded entries; `mapPath.sourceOf(declared)` finds one.
99
103
  */
100
- function targetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label }) {
104
+ function targetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label, managedRoots = defaultManagedRoots(env) }) {
105
+ const staged = new Map();
106
+ const mapper = buildTargetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label, managedRoots, staged });
107
+ mapper.staged = staged;
108
+ mapper.sourceOf = (declared) => staged.get(declared);
109
+ mapper.resolve = () => staged.resolve;
110
+ return mapper;
111
+ }
112
+
113
+ function buildTargetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label, managedRoots, staged }) {
101
114
  const explicit = spec.cargoTargetRoot;
102
115
  if (explicit !== undefined && explicit !== true && explicit !== false && typeof explicit !== "string") fail(`${label}.cargoTargetRoot must be true or an app-relative Cargo.toml path`);
103
116
  const envDir = typeof env.CARGO_TARGET_DIR === "string" && env.CARGO_TARGET_DIR.trim() !== "";
@@ -114,19 +127,26 @@ function targetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label })
114
127
  const moved = path.resolve(targetDir, normalized.slice(prefix.length + 1));
115
128
  const relative = path.relative(appRoot, moved).split(path.sep).join("/");
116
129
  if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) || /^[A-Za-z]:/.test(relative)) {
130
+ if (isUnderManagedRoot(targetDir, managedRoots)) {
131
+ if (normalized.split("/").includes("..")) fail(`${label}: ${declared} must not contain ..`);
132
+ // The default app-relative location under the app's ignored target directory; the copy is planned by the caller.
133
+ staged.set(normalized, { from: moved, to: normalized });
134
+ staged.resolve = { manifest, expected: targetDir };
135
+ return normalized;
136
+ }
117
137
  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`);
118
138
  }
119
139
  return relative;
120
140
  };
121
141
  }
122
142
 
123
- function winPrePackage({ extras, cwd, appRoot, triple, jobs, timeoutMs, cleanup, stability }) {
143
+ function winPrePackage({ extras, cwd, appRoot, triple, jobs, timeoutMs, cleanup, stability, stage }) {
124
144
  const args = ["exec", "tauri", "build", "--no-bundle", "--target", triple, ...configArgs(extras, appRoot, cwd), ...buildCargoArgs(extras, jobs)];
125
- return recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup, stability, timeoutMs });
145
+ return recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup, stability, stage, timeoutMs });
126
146
  }
127
147
 
128
148
  /** `targets.win.winPackage` → prePackage (`tauri build --no-bundle`) and package (`tauri bundle --bundles nsis`). */
129
- export function expandWinPackageTarget(target, { appRoot, config = {}, env = process.env, resolveTargetRoot = cargoResolveTargetRoot }) {
149
+ export function expandWinPackageTarget(target, { appRoot, config = {}, env = process.env, resolveTargetRoot = cargoResolveTargetRoot, managedRoots }) {
130
150
  if (!target?.winPackage) return withoutRecipe(target);
131
151
  if (target.prePackage !== undefined) fail("targets.win.winPackage replaces prePackage; remove the prePackage script");
132
152
  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");
@@ -145,19 +165,35 @@ export function expandWinPackageTarget(target, { appRoot, config = {}, env = pro
145
165
  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}`);
146
166
  const cwd = rel(plan.appRoot, plan.tauriCwd) || ".";
147
167
  const override = { bundle: { createUpdaterArtifacts: false, windows: { nsis: { installMode: plan.perMachine ? "perMachine" : "currentUser" } } } };
148
- const mapPath = targetRootMapper({ spec: target.winPackage, appRoot: plan.appRoot, cwd, env, resolveTargetRoot, label: "targets.win.winPackage" });
168
+ const mapPath = targetRootMapper({ spec: target.winPackage, appRoot: plan.appRoot, cwd, env, resolveTargetRoot, label: "targets.win.winPackage", ...(managedRoots ? { managedRoots } : {}) });
149
169
  const rawExe = mapPath(rel(plan.appRoot, plan.rawExe));
150
170
  const installerFile = mapPath(rel(plan.appRoot, plan.installerFile));
151
- // Stale outputs are removed before the build, and the raw EXE is polled until it stops changing after it.
171
+ // Stale outputs are removed before the build, and the raw EXE is polled until it stops changing after it (in a managed
172
+ // target directory the poll covers the real EXE before it is copied; the in-app copy is polled again after the copy).
152
173
  const cleanup = cleanStale === false ? undefined : { remove: [rawExe, installerFile] };
153
174
  const timeout = timeoutMs ?? DEFAULT_WIN_TIMEOUT_MS;
175
+ // Broker-managed target directory: the real raw EXE is deleted before the build (it holds the previous signed copy) and
176
+ // the fresh one is copied out once stable; after signing, the signed copy goes back
177
+ // for `tauri bundle` (which embeds the EXE it finds in the real target directory) and the installer is copied out.
178
+ const rawSource = mapPath.sourceOf(rawExe);
179
+ const installerSource = mapPath.sourceOf(installerFile);
180
+ const prePackageStage = rawSource ? { post: [{ from: rawSource.from, to: rawExe, fresh: true }], resolve: mapPath.resolve() } : undefined;
181
+ const packageStage = rawSource || installerSource ? {
182
+ ...(rawSource ? { pre: [{ from: rawExe, to: rawSource.from }], verify: [{ app: rawExe, real: rawSource.from }] } : {}),
183
+ resolve: mapPath.resolve(),
184
+ ...(installerSource ? { post: [{ from: installerSource.from, to: installerFile, fresh: true }] } : {}),
185
+ } : undefined;
186
+ const bundleArgs = ["exec", "tauri", "bundle", "--bundles", "nsis", "--target", plan.triple, ...configArgs(extras, plan.appRoot, cwd), "--config", JSON.stringify(override), ...profileArgs(extras)];
187
+ const bundleCommand = packageStage
188
+ ? recipeBuildCommand({ extras: { prepare: [], postBuild: [] }, build: { cmd: "pnpm", args: bundleArgs, cwd }, env: QUIET_TAURI_ENV, stage: packageStage, timeoutMs: timeout })
189
+ : { cmd: "pnpm", args: bundleArgs, cwd, env: QUIET_TAURI_ENV, timeoutMs: timeout };
154
190
  // `recipe` is set only here: a hand-written one is dropped so it can never select a recipe path.
155
191
  const { winPackage, recipe: _ignoredWin, ...rest } = target;
156
192
  return {
157
193
  ...rest,
158
194
  signingContract: "windows-raw-exe-authenticode-before-nsis-v1",
159
- prePackage: winPrePackage({ extras, cwd, appRoot: plan.appRoot, triple: plan.triple, jobs, timeoutMs: timeout, cleanup, stability: { files: [rawExe] } }),
160
- 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 },
195
+ prePackage: winPrePackage({ extras, cwd, appRoot: plan.appRoot, triple: plan.triple, jobs, timeoutMs: timeout, cleanup, stability: { files: [rawExe] }, stage: prePackageStage }),
196
+ package: bundleCommand,
161
197
  sign: { ...(target.sign ?? {}), prePackageFiles: [rawExe], files: [installerFile] },
162
198
  recipe: { kind: "winPackage", productName: plan.productName, updater: plan.updater },
163
199
  };
@@ -185,7 +221,7 @@ function macCleanupSpec(value) {
185
221
  * `updater: true | "<app-relative .tar.gz>"` emits the Tauri updater tarball and its signature from the signed, stapled .app:
186
222
  * it prepends a `create-mac-updater` command to `postSign` (default output `<App>.app.tar.gz` beside the app's working root).
187
223
  */
188
- export function expandMacPackageTarget(target, { appRoot, env = process.env, resolveTargetRoot = cargoResolveTargetRoot }) {
224
+ export function expandMacPackageTarget(target, { appRoot, env = process.env, resolveTargetRoot = cargoResolveTargetRoot, managedRoots }) {
189
225
  if (!target?.macPackage) return withoutRecipe(target);
190
226
  const spec = target.macPackage;
191
227
  if (!isObject(spec)) fail("targets.mac.macPackage must be an object");
@@ -214,7 +250,7 @@ export function expandMacPackageTarget(target, { appRoot, env = process.env, res
214
250
  const args = ["exec", "tauri", "build", "--target", triple, "--bundles", "app", "--no-sign", ...configArgs(extras, appRoot, cwd), "--config", JSON.stringify(override)];
215
251
  if (spec.features?.length) args.push("--features", spec.features.join(","));
216
252
  args.push(...profileArgs(extras));
217
- const mapPath = targetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label: "targets.mac.macPackage" });
253
+ const mapPath = targetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label: "targets.mac.macPackage", ...(managedRoots ? { managedRoots } : {}) });
218
254
  const app = mapPath(target.notarize.app);
219
255
  const entryFile = (entry) => (typeof entry === "string" ? entry : entry?.file);
220
256
  const declaredFiles = target.sign?.prePackageFiles ?? [target.notarize.app];
@@ -230,7 +266,11 @@ export function expandMacPackageTarget(target, { appRoot, env = process.env, res
230
266
  ...(cleanupSpec.staleApps || cleanupSpec.lsclean ? { apps: { dir: path.posix.dirname(appPosix), remove: cleanupSpec.staleApps === true, unregister: cleanupSpec.lsclean === true } } : {}),
231
267
  }
232
268
  : undefined;
233
- const wrapped = extras.prepare.length || extras.postBuild.length || cleanup;
269
+ // Broker-managed target directory: the built .app (and any other declared file under the target) is copied to its app-relative location.
270
+ // An entry inside another staged entry (a file within the .app) is covered by copying the outer one.
271
+ const macEntries = [...mapPath.staged.values()].filter((entry) => ![...mapPath.staged.values()].some((outer) => outer !== entry && entry.to.startsWith(`${outer.to}/`)));
272
+ const macStage = macEntries.length ? { post: macEntries.map((entry) => ({ ...entry, fresh: true })), resolve: mapPath.resolve() } : undefined;
273
+ const wrapped = extras.prepare.length || extras.postBuild.length || cleanup || macStage;
234
274
  const { macPackage, recipe: _ignoredMac, ...rest } = target;
235
275
  // The updater tarball is made from the signed .app, so it runs in postSign, ahead of any postSign the app declares.
236
276
  const updaterOutput = spec.updater === true ? `${productName}.app.tar.gz` : typeof spec.updater === "string" ? relativeInside(spec.updater, "targets.mac.macPackage.updater") : undefined;
@@ -243,7 +283,7 @@ export function expandMacPackageTarget(target, { appRoot, env = process.env, res
243
283
  return {
244
284
  ...rest,
245
285
  ...postSign,
246
- prePackage: wrapped ? recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup }) : { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
286
+ prePackage: wrapped ? recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup, stage: macStage }) : { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
247
287
  sign: { ...(target.sign ?? {}), prePackageFiles },
248
288
  notarize: { ...target.notarize, app, appBeforePackage: true },
249
289
  dmg: isObject(target.dmg) ? target.dmg : {},
@@ -289,8 +329,8 @@ export function expandIosPackageTarget(target, { appRoot: _appRoot } = {}) {
289
329
  }
290
330
 
291
331
  /** Expand whichever declarative package key the target carries. */
292
- export function expandPackageRecipeTarget(target, { platform, appRoot, config, env, resolveTargetRoot }) {
293
- const injected = { ...(env ? { env } : {}), ...(resolveTargetRoot ? { resolveTargetRoot } : {}) };
332
+ export function expandPackageRecipeTarget(target, { platform, appRoot, config, env, resolveTargetRoot, managedRoots }) {
333
+ const injected = { ...(env ? { env } : {}), ...(resolveTargetRoot ? { resolveTargetRoot } : {}), ...(managedRoots ? { managedRoots } : {}) };
294
334
  if (platform === "win") return expandWinPackageTarget(target, { appRoot, config, ...injected });
295
335
  if (platform === "mac") return expandMacPackageTarget(target, { appRoot, config, ...injected });
296
336
  if (platform === "ios") return expandIosPackageTarget(target, { appRoot });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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/preflight.mjs CHANGED
@@ -193,6 +193,7 @@ export function collectPreflight({
193
193
  ownership = true,
194
194
  ownershipEvidenceFactory = createOwnershipEvidence,
195
195
  ownershipScope = null,
196
+ ownershipScopeError = null,
196
197
  runtimeBudgetAdoption = appRuntimeBudgetAdoptionCheck,
197
198
  freeDisk = freeDiskGb,
198
199
  projectType,
@@ -333,7 +334,10 @@ export function collectPreflight({
333
334
  if (adoption) checks.push(adoption);
334
335
  }
335
336
 
336
- if (repoRoot && ownership) {
337
+ if (repoRoot && ownership && ownershipScopeError) {
338
+ // The target's scan scope could not be determined (the build refuses the same config): fail closed, never scan wider.
339
+ checks.push(fail("ownership", `ownership scope refused: ${ownershipScopeError}`, "fix the target's ownership scope configuration; the build refuses it too"));
340
+ } else if (repoRoot && ownership) {
337
341
  // An app living inside the RightKit workspace repository is scoped to its own directory.
338
342
  const workspace = existsSync(path.join(repoRoot, "tools", "rightkit", "packages", "release"));
339
343
  checks.push(ownershipCheck(repoRoot, { scanRoot: workspace && appRoot ? appRoot : repoRoot, scope: ownershipScope, evidenceFactory: ownershipEvidenceFactory }));
package/recipe-build.mjs CHANGED
@@ -19,10 +19,21 @@
19
19
  // stability: { files: [file], intervalMs?, checks?, timeoutMs? } runs right AFTER the build step: each file must exist
20
20
  // and keep the same size and mtime over `checks` consecutive polls `intervalMs` apart (defaults 2 and 1000 ms,
21
21
  // timeout 120 s), so a hung or still-writing Tauri process cannot hand a half-written EXE to signing.
22
+ // stage: { pre?: [{ from: app-relative, to: absolute }], post?: [{ from: absolute, to: app-relative, fresh?: bool }] }
23
+ // the broker-managed target directory shape (bogusyogi/rightkit#924, managed-target-stage.mjs): `pre` copies
24
+ // app files into the real target directory right before the build step (the signed raw EXE, which tauri bundle
25
+ // reads from there); `post` waits for each real output to be stable (the stability settings, or their defaults), then copies it (a missing output skips the wait and fails the copy) to its app-relative location right after the build, and with
26
+ // `fresh` the absolute source is deleted before the build step so a missing output cannot be a leftover. Every
27
+ // copy deletes a stale destination first, is atomic, and is verified by sha256; a missing source fails the step.
28
+ // `pre` never creates the broker-side parent directory. `verify: [{ app, real }]` requires the real file to equal the
29
+ // signed app file before and after the build step; `resolve: { manifest, expected }` re-runs cargo metadata and
30
+ // requires the planned target directory before and after it (a temporary bridge until the broker exposes artifacts).
22
31
  import { existsSync as nodeExistsSync, readFileSync, readdirSync as nodeReaddirSync, rmSync as nodeRmSync, statSync as nodeStatSync } from "node:fs";
23
32
  import path from "node:path";
24
33
  import { pathToFileURL } from "node:url";
25
34
  import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
35
+ import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
36
+ import { assertTargetDirUnchanged, removeFreshSources, stageArtifacts, verifySameContent } from "./managed-target-stage.mjs";
26
37
 
27
38
  export const RECIPE_BUILD_PLAN_VERSION = 1;
28
39
  export const RECIPE_PHASES = Object.freeze(["prepare", "build", "postBuild"]);
@@ -39,7 +50,9 @@ export class RecipeBuildError extends Error {
39
50
  const fail = (code, message, details) => { throw new RecipeBuildError(code, message, details); };
40
51
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
41
52
  const STEP_KEYS = new Set(["phase", "cmd", "args", "cwd", "env"]);
42
- const PLAN_KEYS = new Set(["version", "steps", "cleanup", "stability"]);
53
+ const PLAN_KEYS = new Set(["version", "steps", "cleanup", "stability", "stage"]);
54
+ const STAGE_KEYS = new Set(["pre", "post", "verify", "resolve"]);
55
+ const STAGE_ENTRY_KEYS = new Set(["from", "to", "fresh"]);
43
56
  const CLEANUP_KEYS = new Set(["remove", "apps", "dmgVolumes"]);
44
57
  const APPS_KEYS = new Set(["dir", "remove", "unregister"]);
45
58
  const STABILITY_KEYS = new Set(["files", "intervalMs", "checks", "timeoutMs"]);
@@ -94,6 +107,53 @@ function validateStability(value, errors) {
94
107
  return { files, ...(value.intervalMs === undefined ? {} : { intervalMs: value.intervalMs }), ...(value.checks === undefined ? {} : { checks: value.checks }), ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }) };
95
108
  }
96
109
 
110
+ function validateStage(value, errors) {
111
+ if (value === undefined) return undefined;
112
+ if (!isObject(value)) { errors.push("stage must be an object"); return undefined; }
113
+ unknownKeys(value, STAGE_KEYS, "stage", errors);
114
+ const out = {};
115
+ const absolute = (item, label) => {
116
+ if (typeof item !== "string" || !path.isAbsolute(item) || item.split(/[\\/]/).includes("..")) { errors.push(`${label} must be an absolute path without ..: ${JSON.stringify(item)}`); return undefined; }
117
+ return item;
118
+ };
119
+ for (const [side, appKey, outerKey] of [["pre", "from", "to"], ["post", "to", "from"]]) {
120
+ if (value[side] === undefined) continue;
121
+ if (!Array.isArray(value[side])) { errors.push(`stage.${side} must be a list of { from, to }`); continue; }
122
+ out[side] = value[side].map((entry, index) => {
123
+ const label = `stage.${side}[${index}]`;
124
+ if (!isObject(entry)) { errors.push(`${label} must be an object`); return undefined; }
125
+ unknownKeys(entry, STAGE_ENTRY_KEYS, label, errors);
126
+ if (entry.fresh !== undefined && typeof entry.fresh !== "boolean") errors.push(`${label}.fresh must be a boolean`);
127
+ if (side === "pre" && entry.fresh !== undefined) errors.push(`${label}.fresh is only valid in stage.post`);
128
+ const inner = insideApp(entry[appKey], `${label}.${appKey}`, errors, { strict: true });
129
+ const outer = absolute(entry[outerKey], `${label}.${outerKey}`);
130
+ if (!inner || !outer) return undefined;
131
+ return side === "pre" ? { from: inner, to: outer } : { from: outer, to: inner, ...(entry.fresh === true ? { fresh: true } : {}) };
132
+ }).filter(Boolean);
133
+ }
134
+ if (value.verify !== undefined) {
135
+ if (!Array.isArray(value.verify)) errors.push("stage.verify must be a list of { app, real }");
136
+ else out.verify = value.verify.map((entry, index) => {
137
+ const label = `stage.verify[${index}]`;
138
+ if (!isObject(entry)) { errors.push(`${label} must be an object`); return undefined; }
139
+ unknownKeys(entry, new Set(["app", "real"]), label, errors);
140
+ const app = insideApp(entry.app, `${label}.app`, errors, { strict: true });
141
+ const real = absolute(entry.real, `${label}.real`);
142
+ return app && real ? { app, real } : undefined;
143
+ }).filter(Boolean);
144
+ }
145
+ if (value.resolve !== undefined) {
146
+ if (!isObject(value.resolve)) errors.push("stage.resolve must be { manifest, expected }");
147
+ else {
148
+ unknownKeys(value.resolve, new Set(["manifest", "expected"]), "stage.resolve", errors);
149
+ const manifest = absolute(value.resolve.manifest, "stage.resolve.manifest");
150
+ const expected = absolute(value.resolve.expected, "stage.resolve.expected");
151
+ if (manifest && expected) out.resolve = { manifest, expected };
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
97
157
  /** Validate a plan strictly; every problem is reported at once as RECIPE_BUILD_PLAN_INVALID. Returns the normalized plan. */
98
158
  export function validateRecipePlan(plan) {
99
159
  const errors = [];
@@ -123,8 +183,9 @@ export function validateRecipePlan(plan) {
123
183
  if (builds !== 1) errors.push(`exactly one build step is required, found ${builds}`);
124
184
  const cleanup = validateCleanup(plan.cleanup, errors);
125
185
  const stability = validateStability(plan.stability, errors);
186
+ const stage = validateStage(plan.stage, errors);
126
187
  if (errors.length) fail("RECIPE_BUILD_PLAN_INVALID", `invalid recipe plan:\n${errors.map((line) => `- ${line}`).join("\n")}`, { errors });
127
- return { version: RECIPE_BUILD_PLAN_VERSION, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}) };
188
+ return { version: RECIPE_BUILD_PLAN_VERSION, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}), ...(stage ? { stage } : {}) };
128
189
  }
129
190
 
130
191
  // cmd.exe would reinterpret these in a shell launch; refuse rather than escape.
@@ -258,17 +319,29 @@ export function waitForStableArtifacts(stability, { appRoot, log = () => {}, sta
258
319
 
259
320
  /**
260
321
  * Run the plan: cleanup, prepare steps, the build step, stability polling, then postBuild steps, strictly in order.
261
- * Returns { steps: [{ phase, cmd, status }], cleaned, stable }. The first failure throws RECIPE_BUILD_STEP_FAILED and
322
+ * Returns { steps: [{ phase, cmd, status }], cleaned, stable, staged }. The first failure throws RECIPE_BUILD_STEP_FAILED and
262
323
  * nothing after it runs.
263
324
  * Options: appRoot (absolute), env, platform, log, spawnSync, fs, sleep, now, statSync (injected in tests).
264
325
  */
265
- export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform = process.platform, log = () => {}, spawnSync = nodeSpawnSync, fs, sleep, now, statSync } = {}) {
326
+ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform = process.platform, log = () => {}, spawnSync = nodeSpawnSync, fs, sleep, now, statSync, resolveTargetRoot = cargoResolveTargetRoot } = {}) {
266
327
  if (typeof appRoot !== "string" || !path.isAbsolute(appRoot)) fail("RECIPE_BUILD_PLAN_INVALID", "appRoot must be an absolute path");
267
328
  const plan = validateRecipePlan(rawPlan);
268
329
  const done = [];
269
330
  const cleaned = runRecipeCleanup(plan.cleanup, { appRoot, platform, log, spawnSync, ...(fs ? { fs } : {}) });
270
331
  let stable = [];
332
+ let staged = [];
333
+ const stagePost = plan.stage?.post ?? [];
334
+ const resolveEntry = (entry, appKey) => ({ ...entry, [appKey]: path.resolve(appRoot, entry[appKey]) });
271
335
  for (const [index, step] of plan.steps.entries()) {
336
+ const verifyReal = (what) => { for (const entry of plan.stage?.verify ?? []) verifySameContent({ app: path.resolve(appRoot, entry.app), real: entry.real }, { what, ...(fs ? { fs } : {}) }); };
337
+ const checkDir = (what) => { if (plan.stage?.resolve) assertTargetDirUnchanged(plan.stage.resolve, resolveTargetRoot, { what }); };
338
+ if (step.phase === "build" && plan.stage) {
339
+ checkDir("before the build step");
340
+ removeFreshSources(stagePost, { ...(fs ? { fs } : {}), log });
341
+ staged = stageArtifacts((plan.stage.pre ?? []).map((entry) => resolveEntry(entry, "from")), { ...(fs ? { fs } : {}), log, requireParent: true });
342
+ // Immediately before the step that embeds the real EXE: it must be exactly the signed in-app copy.
343
+ verifyReal("after the copy-back, before the build step");
344
+ }
272
345
  const launch = resolveLaunch({ cmd: step.cmd, args: step.args, platform, env });
273
346
  const cwd = path.resolve(appRoot, step.cwd ?? ".");
274
347
  log(`recipe-build: [${step.phase}] ${step.cmd} ${step.args.join(" ")}`.trimEnd());
@@ -277,9 +350,13 @@ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform =
277
350
  fail("RECIPE_BUILD_STEP_FAILED", `recipe-build ${step.phase} step ${index} (${step.cmd}) failed (${result?.error?.message ?? `exit ${result?.status ?? result?.signal ?? "unknown"}`}); later steps were not run`, { phase: step.phase, index, cmd: step.cmd, status: result?.status ?? null });
278
351
  }
279
352
  done.push({ phase: step.phase, cmd: step.cmd, status: 0 });
353
+ if (step.phase === "build" && plan.stage) { verifyReal("after the build step"); checkDir("after the build step"); }
354
+ // The REAL outputs must stop changing before anything is copied: a half-written EXE is never staged.
355
+ if (step.phase === "build" && stagePost.length) waitForStableArtifacts({ ...(plan.stability ?? {}), files: stagePost.map((entry) => entry.from).filter((file) => (fs?.existsSync ?? nodeExistsSync)(file)) }, { appRoot, log, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}), ...(statSync ? { statSync } : {}) });
356
+ if (step.phase === "build" && stagePost.length) staged.push(...stageArtifacts(stagePost.map((entry) => resolveEntry(entry, "to")), { ...(fs ? { fs } : {}), log }));
280
357
  if (step.phase === "build") stable = waitForStableArtifacts(plan.stability, { appRoot, log, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}), ...(statSync ? { statSync } : {}) });
281
358
  }
282
- return { steps: done, cleaned, stable };
359
+ return { steps: done, cleaned, stable, staged };
283
360
  }
284
361
 
285
362
  export function parseRecipeBuildArgs(argv) {
@@ -48,6 +48,8 @@ import path from "node:path";
48
48
  import { spawnSync } from "./background-process.mjs";
49
49
  import { featuresInTokens, resolveCargoManifestPath, scriptTarget, tokenize } from "./qa-build-features.mjs";
50
50
  import { isNativeBinaryHeader } from "./qa-control-scan.mjs";
51
+ import { nodeHostTriple } from "./stage-sidecar-command.mjs";
52
+ import { WIN_PACKAGE_DEFAULT_TRIPLE } from "./win-package-recipe.mjs";
51
53
 
52
54
  export const TEST_MODE_MARKER = "RIGHTKIT-TEST-MODE-COMPILED"; // identical to `TEST_MODE_MARKER_TEXT` in rightkit-daemon/src/mode.rs
53
55
  const CHUNK = 4 * 1024 * 1024;
@@ -164,8 +166,45 @@ export function resolveFeatureClosure(options = {}) {
164
166
  return resolveCargoTree(options);
165
167
  }
166
168
 
169
+ const MAC_SLICES = ["aarch64-apple-darwin", "x86_64-apple-darwin"];
170
+ const UPDATER_PLATFORM_TRIPLES = {
171
+ "darwin-aarch64": ["mac", "aarch64-apple-darwin"],
172
+ "darwin-x86_64": ["mac", "x86_64-apple-darwin"],
173
+ "windows-x86_64": ["win", "x86_64-pc-windows-msvc"],
174
+ "windows-aarch64": ["win", "aarch64-pc-windows-msvc"],
175
+ };
176
+ const PLATFORM_HOST_OS = { win: "windows", mac: "apple-darwin" };
177
+
178
+ /**
179
+ * The Rust triples a release of `target` ships, evaluated on the RAW config target (before recipe expansion), so the
180
+ * feature closure checks exactly the slices that ship and never asks cargo for `--target all` (the RustRight broker
181
+ * refuses it). First matching rule wins. Throws `release-switch-target-unknown` when no shipped triple can be named.
182
+ */
183
+ export function shippedRustTriples({ platform, target, hostTriple = nodeHostTriple } = {}) {
184
+ const explicit = target?.cargoTarget ?? target?.targetTriple ?? target?.rustTarget;
185
+ if (explicit) return explicit === "universal-apple-darwin" ? [...MAC_SLICES] : [explicit];
186
+ if (target?.winPackage) return [target.winPackage.triple ?? WIN_PACKAGE_DEFAULT_TRIPLE];
187
+ if (target?.macPackage) {
188
+ const spec = target.macPackage;
189
+ if (spec.triple) return spec.triple === "universal-apple-darwin" ? [...MAC_SLICES] : [spec.triple];
190
+ if (spec.universal === false) return [hostTriple()];
191
+ return [...MAC_SLICES];
192
+ }
193
+ if (platform === "ios") return ["aarch64-apple-ios"];
194
+ if (platform === "win" || platform === "mac") {
195
+ const fromUpdater = [...new Set((target?.updater?.artifacts ?? [])
196
+ .map((artifact) => UPDATER_PLATFORM_TRIPLES[artifact?.platform])
197
+ .filter((entry) => entry && entry[0] === platform)
198
+ .map((entry) => entry[1]))];
199
+ if (fromUpdater.length) return fromUpdater;
200
+ const host = hostTriple();
201
+ if (host.includes(PLATFORM_HOST_OS[platform])) return [host];
202
+ }
203
+ throw new Error(`release-switch-target-unknown: cannot name the Rust triple(s) the ${platform} release ships; declare targets.${platform}.cargoTarget`);
204
+ }
205
+
167
206
  /** Shared Cargo tree resolver; keeps each build command's feature selection separate. */
168
- export function resolveCargoTree({ root, manifest, cargoManifest, rust, features = [], scripts, triple, commandTexts = [],
207
+ export function resolveCargoTree({ root, manifest, cargoManifest, rust, features = [], scripts, triple, triples, commandTexts = [],
169
208
  edges = "features,no-dev", prefix, noDefaultFeatures = false, label = "feature closure", run = spawnSync } = {}) {
170
209
  const { rel, declared, candidates } = resolveCargoManifestPath({ root, cargoManifest, manifest });
171
210
  if (!rel) {
@@ -175,8 +214,11 @@ export function resolveCargoTree({ root, manifest, cargoManifest, rust, features
175
214
  if (declared.length && !declared.some((c) => existsSync(path.join(root, c)))) {
176
215
  throw new Error(`release-switch-manifest-missing: declared manifest ${declared.join(", ")} does not exist under ${root}`);
177
216
  }
178
- const baseArgs = ["tree", "--color", "never", "-e", edges, "--locked", "--manifest-path", path.join(root, rel), "--target", triple || "all"];
217
+ const baseArgs = ["tree", "--color", "never", "-e", edges, "--locked", "--manifest-path", path.join(root, rel)];
179
218
  if (prefix) baseArgs.push("--prefix", prefix);
219
+ // `triples` may be a function so a target that cannot name its triples still skips cleanly when the app declares no Rust.
220
+ const tripleList = [...new Set((typeof triples === "function" ? triples() : triples) ?? (triple ? [triple] : []))];
221
+ if (tripleList.length === 0) throw new Error(`${label}: no Rust target triple given; the closure never runs \`cargo tree --target all\` (the build broker refuses it). Declare targets.<os>.cargoTarget`);
180
222
  const pkgScripts = scripts ?? readPackageScripts(root);
181
223
  // One cargo tree per distinct (features, no-default-features, manifest) combination, one combination per build command
182
224
  // (flags gathered over the command and the package.json scripts it runs). A `--no-default-features` in one command never
@@ -207,7 +249,8 @@ export function resolveCargoTree({ root, manifest, cargoManifest, rust, features
207
249
  for (const text of texts) comboFor(text);
208
250
  const trees = [];
209
251
  for (const { noDefault, allFeatures, list } of combos.values()) {
210
- const args = [...baseArgs];
252
+ for (const tripleArg of tripleList) {
253
+ const args = [...baseArgs, "--target", tripleArg];
211
254
  if (noDefault) args.push("--no-default-features");
212
255
  if (allFeatures) args.push("--all-features");
213
256
  if (list.length) args.push("--features", list.join(","));
@@ -218,6 +261,7 @@ export function resolveCargoTree({ root, manifest, cargoManifest, rust, features
218
261
  const out = String(result.stdout ?? "");
219
262
  if (!out.trim()) throw new Error(`${label}: \`cargo tree\` printed nothing; cannot verify`);
220
263
  trees.push(out);
264
+ }
221
265
  }
222
266
  const treeText = trees.join("\n");
223
267
  return { treeText, trees, manifest: rel };
package/release.mjs CHANGED
@@ -49,8 +49,8 @@ import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs
49
49
  import { assertReleaseDependencies } from "./release-dependencies.mjs";
50
50
  import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
51
51
  import { expandRecipeBuildCommands } from "./recipe-build.mjs";
52
- import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure } from "./release-switch-scan.mjs";
53
- import { iosOwnershipScope } from "./ownership-scope.mjs";
52
+ import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure, shippedRustTriples } from "./release-switch-scan.mjs";
53
+ import { iosOwnershipScope, preflightOwnershipScope } from "./ownership-scope.mjs";
54
54
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
55
55
  import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
56
56
  import { runIosPackageRecipe } from "./ios-package-recipe.mjs";
@@ -348,8 +348,19 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
348
348
  ...pinnedCargoLocks,
349
349
  ...(config.cargoLocks ?? []).map((entry) => path.resolve(root, entry)),
350
350
  ].filter((entry, index, entries) => entries.indexOf(entry) === index);
351
+ // Same scope the build uses (rightkit#927): an iOS target is checked over its Xcode project and local packages only.
352
+ // The doctor reports a refused scope as an ownership FAIL (never a crash, never a whole-repo scan); the build still throws.
353
+ let ownershipScope = null;
354
+ let ownershipScopeError = null;
355
+ try {
356
+ ownershipScope = preflightOwnershipScope({ repoRoot, appRoot: root, platform, target: projectTarget, nativeAssembly: config.nativeAssembly });
357
+ } catch (error) {
358
+ ownershipScopeError = error instanceof Error ? error.message : String(error);
359
+ }
351
360
  return collectPreflight({
352
361
  platform,
362
+ ownershipScope,
363
+ ownershipScopeError,
353
364
  appRoot: root,
354
365
  repoRoot,
355
366
  app: config.app,
@@ -472,8 +483,8 @@ try {
472
483
  cargoManifest: switchInputs.cargoManifest,
473
484
  rust: config.releaseSwitches?.rust,
474
485
  features: config.releaseSwitches?.features ?? [],
475
- // iOS ships one device triple; "all" is refused by the build broker and would pull in desktop-only targets.
476
- triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? (opts.platform === "ios" ? "aarch64-apple-ios" : undefined),
486
+ // The shipped triples (never "all": the build broker refuses it); evaluated on the raw config target.
487
+ triples: () => shippedRustTriples({ platform: opts.platform, target: config.targets?.[opts.platform] }),
477
488
  commandTexts: closureCommands,
478
489
  });
479
490
  if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
@@ -20,7 +20,7 @@
20
20
  "@rightkit/hooks": "0.1.2",
21
21
  "@rightkit/i18n": "0.2.0",
22
22
  "@rightkit/issues-tracker": "0.1.0",
23
- "@rightkit/legal": "0.6.2",
23
+ "@rightkit/legal": "0.6.3",
24
24
  "@rightkit/legal-ui": "0.4.1",
25
25
  "@rightkit/license": "0.3.0",
26
26
  "@rightkit/logs": "0.1.6",
@@ -32,7 +32,7 @@
32
32
  "@rightkit/qa": "0.6.1",
33
33
  "@rightkit/qa-chrome": "0.1.11",
34
34
  "@rightkit/react-shell": "0.7.2",
35
- "@rightkit/release": "0.6.1",
35
+ "@rightkit/release": "0.6.3",
36
36
  "@rightkit/settings-ui": "0.7.0",
37
37
  "@rightkit/tauri": "0.3.0",
38
38
  "@rightkit/tauri-shell": "0.1.2",
@@ -107,7 +107,8 @@
107
107
  "0.3.1",
108
108
  "0.5.0",
109
109
  "0.6.0",
110
- "0.6.1"
110
+ "0.6.1",
111
+ "0.6.2"
111
112
  ],
112
113
  "@rightkit/license": [
113
114
  "0.1.6",
@@ -226,7 +227,9 @@
226
227
  "0.4.1",
227
228
  "0.5.0",
228
229
  "0.5.1",
229
- "0.6.0"
230
+ "0.6.0",
231
+ "0.6.1",
232
+ "0.6.2"
230
233
  ],
231
234
  "@rightkit/qa": [
232
235
  "0.1.0",
@@ -439,7 +442,7 @@
439
442
  "rightkit-asr": "0.3.0",
440
443
  "rightkit-audio": "0.3.1",
441
444
  "rightkit-automation": "0.6.0",
442
- "rightkit-browser": "0.3.5",
445
+ "rightkit-browser": "0.3.6",
443
446
  "rightkit-cache": "0.2.3",
444
447
  "rightkit-channel-memory": "0.2.1",
445
448
  "rightkit-child-process": "0.5.5",
@@ -449,7 +452,7 @@
449
452
  "rightkit-credentials": "0.4.2",
450
453
  "rightkit-crypto": "0.1.0",
451
454
  "rightkit-daemon": "0.4.0",
452
- "rightkit-db": "0.5.1",
455
+ "rightkit-db": "0.5.2",
453
456
  "rightkit-device": "0.4.0",
454
457
  "rightkit-docs": "0.1.2",
455
458
  "rightkit-embed": "0.6.1",
@@ -464,7 +467,7 @@
464
467
  "rightkit-identity": "0.1.0",
465
468
  "rightkit-job-queue": "0.5.0",
466
469
  "rightkit-license": "0.2.4",
467
- "rightkit-llm": "0.3.0",
470
+ "rightkit-llm": "0.3.1",
468
471
  "rightkit-logs": "0.3.5",
469
472
  "rightkit-mcp": "0.7.0",
470
473
  "rightkit-models": "0.5.1",
@@ -522,7 +525,7 @@
522
525
  "rightkit-asr": "0.3.0",
523
526
  "rightkit-audio": "0.3.1",
524
527
  "rightkit-automation": "0.6.0",
525
- "rightkit-browser": "0.3.5",
528
+ "rightkit-browser": "0.3.6",
526
529
  "rightkit-cache": "0.2.3",
527
530
  "rightkit-channel-memory": "0.2.1",
528
531
  "rightkit-child-process": "0.5.5",
@@ -533,7 +536,7 @@
533
536
  "rightkit-credentials": "0.4.2",
534
537
  "rightkit-crypto": "0.1.0",
535
538
  "rightkit-daemon": "0.4.0",
536
- "rightkit-db": "0.5.1",
539
+ "rightkit-db": "0.5.2",
537
540
  "rightkit-device": "0.4.0",
538
541
  "rightkit-docs": "0.1.2",
539
542
  "rightkit-embed": "0.6.1",
@@ -550,7 +553,7 @@
550
553
  "rightkit-job-queue": "0.5.0",
551
554
  "rightkit-jobs": "0.3.0",
552
555
  "rightkit-license": "0.2.4",
553
- "rightkit-llm": "0.3.0",
556
+ "rightkit-llm": "0.3.1",
554
557
  "rightkit-logs": "0.3.5",
555
558
  "rightkit-mcp": "0.7.0",
556
559
  "rightkit-media": "0.2.6",
@@ -608,7 +611,7 @@
608
611
  "@rightkit/hooks": "0.1.2",
609
612
  "@rightkit/i18n": "0.2.0",
610
613
  "@rightkit/issues-tracker": "0.1.0",
611
- "@rightkit/legal": "0.6.2",
614
+ "@rightkit/legal": "0.6.3",
612
615
  "@rightkit/legal-ui": "0.4.1",
613
616
  "@rightkit/license": "0.3.0",
614
617
  "@rightkit/logs": "0.1.6",
@@ -620,7 +623,7 @@
620
623
  "@rightkit/qa": "0.6.1",
621
624
  "@rightkit/qa-chrome": "0.1.11",
622
625
  "@rightkit/react-shell": "0.7.2",
623
- "@rightkit/release": "0.6.0",
626
+ "@rightkit/release": "0.6.2",
624
627
  "@rightkit/settings-ui": "0.7.0",
625
628
  "@rightkit/shell": "0.1.1",
626
629
  "@rightkit/tauri": "0.3.0",
@@ -642,7 +645,8 @@
642
645
  "0.2.2",
643
646
  "0.3.0",
644
647
  "0.3.1",
645
- "0.3.4"
648
+ "0.3.4",
649
+ "0.3.5"
646
650
  ],
647
651
  "rightkit-models": [
648
652
  "0.2.1",
@@ -670,7 +674,8 @@
670
674
  "0.3.0",
671
675
  "0.3.1",
672
676
  "0.4.0",
673
- "0.4.3"
677
+ "0.4.3",
678
+ "0.5.1"
674
679
  ],
675
680
  "rightkit-embed": [
676
681
  "0.2.1",