@rightkit/release 0.2.70 → 0.2.71

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/build-release.mjs CHANGED
@@ -23,6 +23,7 @@ import path from "node:path";
23
23
  import { spawn, spawnSync } from "node:child_process";
24
24
  import { fileURLToPath, pathToFileURL } from "node:url";
25
25
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
26
+ import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
26
27
  import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
27
28
  import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
28
29
  import { createTargetBridge } from "./target-bridge.mjs";
@@ -48,7 +49,7 @@ const PIPELINE_FINGERPRINT = createHash("sha256")
48
49
  .slice(0, 16);
49
50
 
50
51
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
51
- function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
52
+ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout }) {
52
53
  let version;
53
54
  try {
54
55
  version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
@@ -65,10 +66,7 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
65
66
  // collision is already enforced downstream, and duplicating it here would
66
67
  // reject a legitimate resume of an in-flight build.
67
68
  version: undefined,
68
- cargoLockPaths: [
69
- path.join(appRoot, "src-tauri", "Cargo.lock"),
70
- path.join(appRoot, "Cargo.lock"),
71
- ],
69
+ cargoLockPaths: nativeLayout?.lockCandidates ?? [],
72
70
  });
73
71
  }
74
72
 
@@ -152,7 +150,10 @@ try {
152
150
  }
153
151
  const target = config.targets?.[platform];
154
152
  if (!target?.package) fail(`${config.app} has no ${platform} package command`);
155
- if (platform === "win") assertNsisInPlaceUpgradeContract(appRoot, target.nsisUpgradeContract);
153
+ const nativeLayout = resolveNativeCargoLayout({ appRoot, config });
154
+ if (platform === "win" && target.signingContract === "windows-raw-exe-authenticode-before-nsis-v1") {
155
+ assertNsisInPlaceUpgradeContract(appRoot, target.nsisUpgradeContract);
156
+ }
156
157
  const { requiredInputs, buildInputs } = resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target });
157
158
  const dirtyInputs = dirtyBuildInputs(repoRoot, buildInputs, commit);
158
159
  if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
@@ -170,12 +171,12 @@ try {
170
171
  receiptInputs: ["raw-exe", "installer", "embedding"].map((phase) => path.join(receiptRoot, `windows-${phase}.json`)),
171
172
  } : null;
172
173
  if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
173
- const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
174
- const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
174
+ const cargoLock = nativeLayout.lockPath;
175
+ const cargoToml = nativeLayout.manifestPath;
175
176
  // A broker-managed host is not identified by RIGHTKIT_BUILD_BROKER_SOCKET alone:
176
177
  // that variable is exported by login shells only, while the managed cargo shim is
177
178
  // on PATH for every shell. Detecting on the variable alone made agent (non-login)
178
- // shells take the Cache V2 branch, bridge src-tauri/target into the release cache,
179
+ // shells take the Cache V2 branch, bridge resolved native target into release cache,
179
180
  // and then lose CARGO_TARGET_DIR to the broker's unconditional override — so the
180
181
  // app bundle landed in the broker workspace and packaging failed on an empty cache.
181
182
  const managedCargoTarget = brokerManagedHost(process.env) ? resolveTargetRoot(cargoToml) : null;
@@ -204,7 +205,7 @@ try {
204
205
  // Preflight BEFORE any compile. These preconditions used to be discovered one
205
206
  // per build cycle, minutes deep, with errors that named a symptom instead of a
206
207
  // cause. Blocking problems stop here, in seconds, naming the fix.
207
- const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform });
208
+ const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout });
208
209
  const preflightBlockers = preflightFailures(preflight);
209
210
  if (preflightBlockers.length > 0) {
210
211
  fail(`preflight found ${preflightBlockers.length} blocking problem(s):\n${formatPreflight(preflightBlockers)}`);
@@ -256,7 +257,7 @@ try {
256
257
  tools: toolVersions,
257
258
  createdAt: new Date().toISOString(),
258
259
  };
259
- const targetLink = path.join(appRoot, "src-tauri", "target");
260
+ const targetLink = nativeLayout.targetLink;
260
261
  // ownedRoot is the per-app cache parent holding every fingerprint dir, so a
261
262
  // link left by an earlier fingerprint is recognised as ours and repointed
262
263
  // instead of hard-stopping the build.
@@ -329,7 +330,7 @@ try {
329
330
  checkpoint(stateRoot, "hardened");
330
331
  },
331
332
  seal: async ({ sealedDir }) => {
332
- sealRelease({ configRoot: appRoot, managedCargoTarget, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity });
333
+ sealRelease({ configRoot: appRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity });
333
334
  verifySealedRelease(sealedDir);
334
335
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
335
336
  checkpoint(stateRoot, "sealed");
@@ -369,20 +370,24 @@ function sqlCipherFeatures(text) {
369
370
  .filter((value) => /sqlcipher|openssl/i.test(value));
370
371
  }
371
372
 
372
- function sealRelease({ configRoot, managedCargoTarget, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity }) {
373
+ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity }) {
373
374
  // Every app's right-release.config.mjs names installer/updater artifacts
374
- // relative to the local `src-tauri/target`. On a broker-managed host that
375
+ // relative to its resolved native target. On a broker-managed host that
375
376
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
376
377
  // targetBridge.ensure() deliberately leaves a real local directory alone,
377
378
  // see target-bridge.mjs) so those paths must resolve against the broker's
378
379
  // own target root instead, or every managed build fails at seal after a
379
380
  // full compile, sign, and notarize.
380
381
  const targetPrefix = "src-tauri/target/";
382
+ const effectiveTargetPrefix = nativeLayout?.targetPrefix ?? targetPrefix;
381
383
  const resolveArtifactPath = (file) => {
382
384
  const normalized = file.replaceAll(path.win32.sep, "/");
383
385
  if (managedCargoTarget && normalized.startsWith(targetPrefix)) {
384
386
  return path.join(managedCargoTarget, normalized.slice(targetPrefix.length));
385
387
  }
388
+ if (managedCargoTarget && effectiveTargetPrefix !== targetPrefix && normalized.startsWith(effectiveTargetPrefix)) {
389
+ return path.join(managedCargoTarget, normalized.slice(effectiveTargetPrefix.length));
390
+ }
386
391
  return path.resolve(configRoot, file);
387
392
  };
388
393
  const sources = new Map();
package/cache-command.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  resolveSharedCacheRoot,
14
14
  } from "./cache-policy.mjs";
15
15
  import { resolveReleaseLayout } from "./release-state.mjs";
16
+ import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
16
17
  import { brokerManagedHost } from "./cargo-contract.mjs";
17
18
 
18
19
  // On a broker-managed host, CARGO_TARGET_DIR is not Cache V2's to own: the build
@@ -59,14 +60,16 @@ export async function runCacheCommand(args = process.argv.slice(2), { env = proc
59
60
  if (!config?.app) throw new Error("cache migrate config must expose app");
60
61
  const appRoot = path.dirname(configFile);
61
62
  const configuredTarget = config.targets?.[platform] ?? {};
62
- const lockPath = path.join(appRoot, "src-tauri", "Cargo.lock");
63
- const identity = resolveSharedCacheIdentity({ cargoLockPath: lockPath, cargoTomlPath: path.join(appRoot, "src-tauri", "Cargo.toml"), rustcVerbose: env.RIGHT_RELEASE_RUSTC_VERBOSE, targetTriple: configuredTarget.cargoTarget ?? configuredTarget.targetTriple ?? configuredTarget.rustTarget ?? env.TAURI_ENV_TARGET_TRIPLE, profile: configuredTarget.profile ?? "release" });
63
+ const nativeLayout = resolveNativeCargoLayout({ appRoot, config });
64
+ const lockPath = nativeLayout.lockPath;
65
+ if (!lockPath) throw new Error(`cache migrate could not find Cargo.lock for ${nativeLayout.manifestPath}`);
66
+ const identity = resolveSharedCacheIdentity({ cargoLockPath: lockPath, cargoTomlPath: nativeLayout.manifestPath, rustcVerbose: env.RIGHT_RELEASE_RUSTC_VERBOSE, targetTriple: configuredTarget.cargoTarget ?? configuredTarget.targetTriple ?? configuredTarget.rustTarget ?? env.TAURI_ENV_TARGET_TRIPLE, profile: configuredTarget.profile ?? "release" });
64
67
  const fingerprint = env.RIGHT_RELEASE_CACHE_KEY || identity.fingerprint;
65
68
  const layout = resolveCacheLayout({ cacheRoot, platform, architecture: identity.architecture, app: config.app, fingerprint, kind: "release" });
66
69
  const dryRun = !rest.includes("--apply");
67
- const legacy = resolveReleaseLayout({ repoRoot: findRepoRoot(appRoot), configPath: configFile });
70
+ const legacy = resolveReleaseLayout({ repoRoot: findRepoRoot(appRoot), configPath: configFile, config, nativeLayout });
68
71
  const legacyTargetRoot = path.join(legacy.vaultRoot, "cache", "cargo-target", platform, fingerprint);
69
- const result = migrateLegacyCache({ legacyRoot: path.join(appRoot, "src-tauri", "target"), legacyTargetRoot, legacyCargoHome: path.join(legacy.vaultRoot, "cache", "cargo-home"), legacyReleaseLockPath: path.join(legacy.vaultRoot, "locks", `${platform}.lock.json`), layout, app: config.app, dryRun });
72
+ const result = migrateLegacyCache({ legacyRoot: nativeLayout.targetLink, legacyTargetRoot, legacyCargoHome: path.join(legacy.vaultRoot, "cache", "cargo-home"), legacyReleaseLockPath: path.join(legacy.vaultRoot, "locks", `${platform}.lock.json`), layout, app: config.app, dryRun });
70
73
  return output({ schema: 1, command, cacheRoot, ...result }, json, stdout);
71
74
  }
72
75
  throw new Error("right-release cache: expected status, prune, or migrate");
File without changes
@@ -0,0 +1,239 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const DEFAULT_MANIFEST = path.join("src-tauri", "Cargo.toml");
5
+ const LOCK_SOURCES = new Set(["manifest", "workspace", "root"]);
6
+
7
+ /**
8
+ * Resolve every path which is derived from a native Cargo manifest in one
9
+ * place. RightRelease historically assumed src-tauri; retaining that
10
+ * default keeps existing applications source-compatible while allowing a
11
+ * workspace package to opt into its real manifest.
12
+ */
13
+ export function resolveNativeCargoLayout({
14
+ appRoot,
15
+ config,
16
+ nativeAssembly = config?.nativeAssembly,
17
+ exists = existsSync,
18
+ read = (file) => readFileSync(file, "utf8"),
19
+ requireManifest = false,
20
+ requireLock = false,
21
+ } = {}) {
22
+ if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
23
+ const resolvedAppRoot = path.resolve(appRoot);
24
+ const declaredManifest = nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest;
25
+ const manifestPath = resolveDeclaredPath(resolvedAppRoot, declaredManifest || DEFAULT_MANIFEST);
26
+ if (requireManifest && !exists(manifestPath)) throw new Error(`native Cargo manifest missing: ${manifestPath}`);
27
+
28
+ const manifestText = readIfPresent(manifestPath, exists, read);
29
+ const manifestDir = path.dirname(manifestPath);
30
+ const parsed = parseCargoManifest(manifestText);
31
+ const explicitWorkspace = nativeAssembly?.workspaceRoot ?? nativeAssembly?.cargoWorkspaceRoot;
32
+ const workspaceRoot = explicitWorkspace
33
+ ? normalizeWorkspaceRoot(resolveDeclaredPath(resolvedAppRoot, explicitWorkspace), exists)
34
+ : discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read });
35
+
36
+ const lock = resolveCargoLock({
37
+ appRoot: resolvedAppRoot,
38
+ manifestPath,
39
+ manifestDir,
40
+ workspaceRoot,
41
+ nativeAssembly,
42
+ exists,
43
+ });
44
+ if (requireLock && !lock.path) {
45
+ throw new Error(`native Cargo.lock missing (checked ${lock.candidates.join(", ")})`);
46
+ }
47
+
48
+ const targetDeclaration = nativeAssembly?.targetDir
49
+ ?? nativeAssembly?.targetRoot
50
+ ?? nativeAssembly?.cargoTargetDir;
51
+ const targetLink = resolveDeclaredPath(resolvedAppRoot, targetDeclaration || path.join(relativeFromRoot(resolvedAppRoot, workspaceRoot), "target"));
52
+ const targetPrefix = relativePrefix(resolvedAppRoot, targetLink);
53
+ const manifestPrefix = relativePrefix(resolvedAppRoot, manifestDir);
54
+ const workspacePrefix = relativePrefix(resolvedAppRoot, workspaceRoot);
55
+ const targetPrefixes = [...new Set([
56
+ targetPrefix,
57
+ manifestPrefix ? `${manifestPrefix}target/` : "",
58
+ workspacePrefix ? `${workspacePrefix}target/` : "",
59
+ ].filter(Boolean))].sort((left, right) => right.length - left.length);
60
+ const layout = {
61
+ appRoot: resolvedAppRoot,
62
+ manifestPath,
63
+ cargoManifest: manifestPath,
64
+ manifestDir,
65
+ manifestPrefix,
66
+ workspaceRoot,
67
+ cargoWorkspaceRoot: workspaceRoot,
68
+ workspacePrefix,
69
+ lockPath: lock.path,
70
+ cargoLock: lock.path,
71
+ lockSource: lock.source,
72
+ lockCandidates: lock.candidates,
73
+ targetRoot: targetLink,
74
+ targetDir: targetLink,
75
+ targetLink,
76
+ targetPrefix,
77
+ targetPrefixes,
78
+ targetRootPrefix: targetPrefix,
79
+ native: Boolean(nativeAssembly),
80
+ fallback: !declaredManifest,
81
+ manifest: parsed,
82
+ };
83
+ layout.resolveArtifactPath = (file) => resolveNativeArtifactPath(layout, file);
84
+ return layout;
85
+ }
86
+
87
+ export const resolveCargoLayout = resolveNativeCargoLayout;
88
+ export const resolveNativeCargoManifest = resolveNativeCargoLayout;
89
+ export const resolveCargoManifest = resolveNativeCargoLayout;
90
+
91
+ export function resolveNativeArtifactPath(layout, file) {
92
+ if (!layout?.appRoot) throw new TypeError("native artifact resolution requires a Cargo layout");
93
+ if (!file) throw new TypeError("native artifact path is required");
94
+ const value = String(file).replaceAll("\\", "/");
95
+ const targetPrefixes = layout.targetPrefixes ?? [layout.targetPrefix];
96
+ for (const prefixValue of targetPrefixes) {
97
+ const targetPrefix = stripTrailingSlash(prefixValue || "");
98
+ if (targetPrefix && (value === targetPrefix || value.startsWith(`${targetPrefix}/`))) {
99
+ return path.join(layout.targetRoot, value.slice(targetPrefix.length).replace(/^\/+/, ""));
100
+ }
101
+ }
102
+ return path.resolve(layout.appRoot, file);
103
+ }
104
+
105
+ export function parseCargoManifest(text = "") {
106
+ const cleaned = String(text)
107
+ .replace(/\/\/.*$/gm, "")
108
+ .replace(/#.*$/gm, "");
109
+ const workspaceSection = section(cleaned, "workspace");
110
+ const packageSection = section(cleaned, "package");
111
+ return {
112
+ hasWorkspace: workspaceSection != null,
113
+ workspacePath: stringValue(workspaceSection?.workspace),
114
+ packageWorkspace: stringValue(packageSection?.workspace),
115
+ packageName: stringValue(packageSection?.name),
116
+ };
117
+ }
118
+
119
+ export function findCargoLock({ manifestPath, appRoot, workspaceRoot, nativeAssembly, exists = existsSync } = {}) {
120
+ const resolvedAppRoot = path.resolve(appRoot || path.dirname(manifestPath || DEFAULT_MANIFEST));
121
+ const resolvedManifest = path.resolve(manifestPath || path.join(resolvedAppRoot, DEFAULT_MANIFEST));
122
+ const manifestDir = path.dirname(resolvedManifest);
123
+ return resolveCargoLock({
124
+ appRoot: resolvedAppRoot,
125
+ manifestPath: resolvedManifest,
126
+ manifestDir,
127
+ workspaceRoot: path.resolve(workspaceRoot || manifestDir),
128
+ nativeAssembly,
129
+ exists,
130
+ });
131
+ }
132
+
133
+ export const resolveCargoLockPath = findCargoLock;
134
+
135
+ function resolveCargoLock({ appRoot, manifestPath, manifestDir, workspaceRoot, nativeAssembly, exists }) {
136
+ const declaredPath = nativeAssembly?.cargoLockPath
137
+ ?? nativeAssembly?.cargoLockFile
138
+ ?? nativeAssembly?.lockfilePath
139
+ ?? nativeAssembly?.lockPath;
140
+ const declared = nativeAssembly?.cargoLock ?? nativeAssembly?.lockfile ?? nativeAssembly?.lockSource;
141
+ const sourceDeclaration = nativeAssembly?.cargoLockSource ?? nativeAssembly?.lockSource;
142
+ const source = normalizeLockSource(sourceDeclaration ?? (typeof declared === "string" && LOCK_SOURCES.has(declared) ? declared : undefined));
143
+ if (declaredPath || (declared && !LOCK_SOURCES.has(String(declared)))) {
144
+ const explicit = declaredPath || declared;
145
+ const explicitPath = resolveDeclaredPath(appRoot, explicit);
146
+ return { path: exists(explicitPath) ? explicitPath : undefined, source: "explicit", candidates: [explicitPath] };
147
+ }
148
+ const roots = {
149
+ manifest: manifestDir,
150
+ workspace: workspaceRoot,
151
+ root: appRoot,
152
+ };
153
+ const orderedSources = source ? [source] : [workspaceRoot !== manifestDir ? "workspace" : "manifest", "manifest", "root"];
154
+ const candidates = [];
155
+ for (const candidateSource of orderedSources) {
156
+ const candidate = path.join(roots[candidateSource], "Cargo.lock");
157
+ if (!candidates.includes(candidate)) candidates.push(candidate);
158
+ }
159
+ const found = candidates.find((candidate) => exists(candidate));
160
+ return { path: found, source: found ? orderedSources[candidates.indexOf(found)] : undefined, candidates };
161
+ }
162
+
163
+ function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read }) {
164
+ const parsed = parseCargoManifest(manifestText);
165
+ const declaredWorkspace = parsed.workspacePath ?? parsed.packageWorkspace;
166
+ if (declaredWorkspace) {
167
+ return normalizeWorkspaceRoot(resolveDeclaredPath(manifestDir, declaredWorkspace), exists);
168
+ }
169
+ if (parsed.hasWorkspace) return manifestDir;
170
+ let current = manifestDir;
171
+ for (;;) {
172
+ const candidate = path.join(current, "Cargo.toml");
173
+ if (candidate !== manifestPath && exists(candidate) && parseCargoManifest(readIfPresent(candidate, exists, read)).hasWorkspace) return current;
174
+ if (current === appRoot) break;
175
+ const parent = path.dirname(current);
176
+ if (parent === current || !isWithin(appRoot, parent)) break;
177
+ current = parent;
178
+ }
179
+ return manifestDir;
180
+ }
181
+
182
+ function normalizeWorkspaceRoot(candidate, exists) {
183
+ const resolved = path.resolve(candidate);
184
+ if (path.basename(resolved).toLowerCase() === "cargo.toml") return path.dirname(resolved);
185
+ if (exists(resolved) && !path.extname(resolved)) return resolved;
186
+ if (exists(path.join(resolved, "Cargo.toml"))) return resolved;
187
+ return resolved;
188
+ }
189
+
190
+ function section(text, name) {
191
+ const expression = new RegExp(`(?:^|\\n)\\s*\\[${escapeRegExp(name)}\\]\\s*\\n([\\s\\S]*?)(?=\\n\\s*\\[|$)`, "m");
192
+ const match = text.match(expression);
193
+ if (!match) return undefined;
194
+ const values = {};
195
+ for (const line of match[1].split("\n")) {
196
+ const assignment = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*$/);
197
+ if (assignment) values[assignment[1]] = assignment[2];
198
+ }
199
+ return values;
200
+ }
201
+
202
+ function stringValue(value) {
203
+ const match = String(value ?? "").match(/^['\"]([^'\"]+)['\"]$/);
204
+ return match?.[1];
205
+ }
206
+
207
+ function normalizeLockSource(value) {
208
+ if (value == null) return undefined;
209
+ const normalized = String(value).toLowerCase();
210
+ return LOCK_SOURCES.has(normalized) ? normalized : undefined;
211
+ }
212
+
213
+ function resolveDeclaredPath(root, value) {
214
+ if (path.isAbsolute(String(value))) return path.normalize(String(value));
215
+ return path.resolve(root, String(value));
216
+ }
217
+
218
+ function relativeFromRoot(root, candidate) {
219
+ const relative = path.relative(root, candidate);
220
+ return relative || ".";
221
+ }
222
+
223
+ function relativePrefix(root, candidate) {
224
+ const relative = path.relative(root, candidate).replaceAll("\\", "/");
225
+ return relative && relative !== "." ? `${relative.replace(/\/+$/, "")}/` : "";
226
+ }
227
+
228
+ function stripTrailingSlash(value) { return String(value).replace(/\/+$/, ""); }
229
+
230
+ function readIfPresent(file, exists, read) {
231
+ try { return exists(file) ? read(file) : ""; } catch { return ""; }
232
+ }
233
+
234
+ function isWithin(root, candidate) {
235
+ const relative = path.relative(root, candidate);
236
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
237
+ }
238
+
239
+ function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
@@ -0,0 +1,259 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const NATIVE_FINALIZATION_SCHEMA = 1;
6
+ export const NATIVE_PROVENANCE_SCHEME = "rightkit-release";
7
+
8
+ const PLACEHOLDER = /(?:placeholder|fabricat|pending|unknown|example|dummy|todo|changeme)/i;
9
+ const STATUS = new Set(["complete", "completed", "pass", "passed", "verified"]);
10
+
11
+ export function sha256Bytes(value) {
12
+ return createHash("sha256").update(value).digest("hex");
13
+ }
14
+
15
+ export function sha256File(file) {
16
+ return sha256Bytes(readFileSync(file));
17
+ }
18
+
19
+ export function normalizeDigest(value, label = "digest") {
20
+ const digest = String(value ?? "").toLowerCase().replace(/^sha256:/, "");
21
+ if (!/^[0-9a-f]{64}$/.test(digest)) throw new Error(`${label} must be a SHA-256 hex digest`);
22
+ return digest;
23
+ }
24
+
25
+ export function canonicalSignedArtifacts(artifacts = []) {
26
+ if (!Array.isArray(artifacts)) throw new TypeError("signedArtifacts must be an array");
27
+ return artifacts.map((artifact) => {
28
+ if (typeof artifact === "string") return { path: artifact };
29
+ if (!artifact || typeof artifact !== "object") throw new TypeError("signed artifact must be a path or object");
30
+ const value = { ...artifact };
31
+ if (!value.path && value.file) value.path = value.file;
32
+ if (!value.path) throw new Error("signed artifact path is required");
33
+ if (value.sha256 != null) value.sha256 = normalizeDigest(value.sha256, `signed artifact ${value.path}`);
34
+ if (value.sizeBytes != null && (!Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0)) {
35
+ throw new Error(`signed artifact size must be a non-negative integer: ${value.path}`);
36
+ }
37
+ return {
38
+ path: String(value.path).replaceAll("\\", "/"),
39
+ ...(value.sha256 ? { sha256: value.sha256 } : {}),
40
+ ...(value.sizeBytes != null ? { sizeBytes: value.sizeBytes } : {}),
41
+ };
42
+ }).sort((left, right) => left.path.localeCompare(right.path));
43
+ }
44
+
45
+ export function canonicalPackageIdentity(identity = {}) {
46
+ if (typeof identity === "string") identity = { name: identity };
47
+ if (!identity || typeof identity !== "object" || Array.isArray(identity)) throw new TypeError("package identity must be an object");
48
+ const result = {};
49
+ for (const key of ["name", "path", "kind", "version", "target", "platform", "architecture"]) {
50
+ if (identity[key] != null) result[key] = String(identity[key]).replaceAll("\\", "/");
51
+ }
52
+ if (Array.isArray(identity.artifacts)) {
53
+ result.artifacts = identity.artifacts.map((value) => String(value).replaceAll("\\", "/")).sort();
54
+ }
55
+ if (!Object.keys(result).length) throw new Error("package identity must include a stable name, path, kind, or artifact list");
56
+ return result;
57
+ }
58
+
59
+ /**
60
+ * Mint identity from signed files and package identity. Archive bytes are
61
+ * included when available; before packaging, identity remains bound to the
62
+ * signed artifact set plus stable package identity and is completed later by
63
+ * createNativeFinalizationReceipt.
64
+ */
65
+ export function mintNativeProvenance({
66
+ app,
67
+ version,
68
+ platform,
69
+ architecture,
70
+ targetTriple,
71
+ signedArtifacts = [],
72
+ packageIdentity,
73
+ archiveSha256,
74
+ } = {}) {
75
+ const identity = canonicalPackageIdentity(packageIdentity);
76
+ const signed = canonicalSignedArtifacts(signedArtifacts);
77
+ if (!app || !version || !platform || !architecture) throw new Error("native provenance identity is incomplete");
78
+ const archive = archiveSha256 == null ? undefined : normalizeDigest(archiveSha256, "archiveSha256");
79
+ const payload = {
80
+ app: String(app),
81
+ version: String(version),
82
+ platform: String(platform),
83
+ architecture: String(architecture),
84
+ ...(targetTriple ? { targetTriple: String(targetTriple) } : {}),
85
+ signedArtifacts: signed,
86
+ packageIdentity: identity,
87
+ ...(archive ? { archiveSha256: archive } : {}),
88
+ };
89
+ const digest = sha256Bytes(JSON.stringify(payload));
90
+ const pathParts = [app, version, platform, architecture].map((value) => encodeURIComponent(String(value)));
91
+ return `${NATIVE_PROVENANCE_SCHEME}://${pathParts.join("/")}/${digest}`;
92
+ }
93
+
94
+ export function validateNativeProvenance(value, { expectedScheme = NATIVE_PROVENANCE_SCHEME } = {}) {
95
+ const provenance = String(value ?? "");
96
+ if (!provenance.startsWith(`${expectedScheme}://`)) {
97
+ throw new Error(`native finalizer provenance must use ${expectedScheme}://`);
98
+ }
99
+ if (PLACEHOLDER.test(provenance)) throw new Error("native finalizer returned placeholder provenance");
100
+ const digest = provenance.slice(provenance.lastIndexOf("/") + 1);
101
+ normalizeDigest(digest, "native provenance identity");
102
+ return provenance;
103
+ }
104
+
105
+ export function finalizationArchive(file, { root } = {}) {
106
+ const resolved = root && !path.isAbsolute(String(file)) ? path.resolve(root, file) : path.resolve(String(file));
107
+ if (!existsSync(resolved)) throw new Error(`native finalization archive missing: ${resolved}`);
108
+ const stat = statSync(resolved);
109
+ if (!stat.isFile()) throw new Error(`native finalization archive is not a file: ${resolved}`);
110
+ return {
111
+ path: root ? path.relative(root, resolved).replaceAll("\\", "/") : resolved.replaceAll("\\", "/"),
112
+ sha256: sha256File(resolved),
113
+ sizeBytes: stat.size,
114
+ };
115
+ }
116
+
117
+ export function createNativeFinalizationReceipt({
118
+ app,
119
+ version,
120
+ platform,
121
+ architecture,
122
+ targetTriple,
123
+ signedArtifacts = [],
124
+ packageIdentity,
125
+ archive,
126
+ archivePath,
127
+ archiveSha256,
128
+ finalizerOutput,
129
+ root,
130
+ now = new Date().toISOString(),
131
+ } = {}) {
132
+ const identity = canonicalPackageIdentity(packageIdentity);
133
+ const signed = canonicalSignedArtifacts(signedArtifacts);
134
+ let resolvedArchive = archive;
135
+ if (!resolvedArchive && archivePath) resolvedArchive = finalizationArchive(archivePath, { root });
136
+ if (resolvedArchive) {
137
+ const archiveFile = resolvedArchive.path || resolvedArchive.file;
138
+ const digest = resolvedArchive.sha256 ?? archiveSha256;
139
+ if (!digest) throw new Error("native finalization archive digest is required");
140
+ if (archiveFile && root) {
141
+ const observed = finalizationArchive(archiveFile, { root });
142
+ if (normalizeDigest(digest, "archiveSha256") !== observed.sha256) throw new Error("native finalization archive digest does not match archive bytes");
143
+ resolvedArchive = observed;
144
+ }
145
+ resolvedArchive = {
146
+ ...resolvedArchive,
147
+ ...(archiveFile ? { path: String(archiveFile).replaceAll("\\", "/") } : {}),
148
+ sha256: normalizeDigest(digest, "archiveSha256"),
149
+ ...(resolvedArchive.sizeBytes != null ? { sizeBytes: resolvedArchive.sizeBytes } : {}),
150
+ };
151
+ } else if (archiveSha256) {
152
+ resolvedArchive = { sha256: normalizeDigest(archiveSha256, "archiveSha256") };
153
+ } else {
154
+ throw new Error("native finalization receipt requires final archive digest");
155
+ }
156
+ const provenance = mintNativeProvenance({
157
+ app,
158
+ version,
159
+ platform,
160
+ architecture,
161
+ targetTriple,
162
+ signedArtifacts: signed,
163
+ packageIdentity: identity,
164
+ archiveSha256: resolvedArchive.sha256,
165
+ });
166
+ const output = finalizerOutput && typeof finalizerOutput === "object" ? finalizerOutput : {};
167
+ if (output.provenance != null) validateNativeProvenance(output.provenance);
168
+ if (output.signedArtifacts != null || output.signed != null) {
169
+ const observed = canonicalSignedArtifacts(output.signedArtifacts ?? output.signed);
170
+ if (JSON.stringify(observed) !== JSON.stringify(signed)) throw new Error("native finalizer signed artifact identity mismatch");
171
+ }
172
+ if (output.packageIdentity != null || output.package != null) {
173
+ const observed = canonicalPackageIdentity(output.packageIdentity ?? output.package);
174
+ if (JSON.stringify(observed) !== JSON.stringify(identity)) throw new Error("native finalizer package identity mismatch");
175
+ }
176
+ const outputArchiveDigest = output.archiveSha256 ?? output.archive?.sha256 ?? output.archive?.digest;
177
+ if (outputArchiveDigest != null && normalizeDigest(outputArchiveDigest, "finalizer archiveSha256") !== resolvedArchive.sha256) {
178
+ throw new Error("native finalizer archive digest does not match final archive");
179
+ }
180
+ const receipt = {
181
+ schema: NATIVE_FINALIZATION_SCHEMA,
182
+ kind: "rightkit-native-finalization",
183
+ status: "verified",
184
+ app: String(app),
185
+ version: String(version),
186
+ platform: String(platform),
187
+ architecture: String(architecture),
188
+ ...(targetTriple ? { targetTriple: String(targetTriple) } : {}),
189
+ provenance,
190
+ signedArtifacts: signed,
191
+ packageIdentity: identity,
192
+ archive: resolvedArchive,
193
+ archiveSha256: resolvedArchive.sha256,
194
+ finalizedAt: now,
195
+ };
196
+ validateNativeFinalizationOutput(receipt, { requireArchive: true });
197
+ return receipt;
198
+ }
199
+
200
+ export function validateNativeFinalizationOutput(output, {
201
+ expectedProvenance,
202
+ expectedArchiveSha256,
203
+ expectedSignedArtifacts,
204
+ expectedPackageIdentity,
205
+ requireArchive = false,
206
+ } = {}) {
207
+ if (!output || typeof output !== "object" || Array.isArray(output)) throw new Error("native finalizer output must be an object");
208
+ if (output.schema != null && output.schema !== NATIVE_FINALIZATION_SCHEMA) throw new Error(`unsupported native finalizer schema: ${output.schema}`);
209
+ const status = String(output.status ?? "").toLowerCase();
210
+ if (!STATUS.has(status)) throw new Error("native finalizer output must have complete, pass, or verified status");
211
+ const provenance = validateNativeProvenance(output.provenance);
212
+ if (expectedProvenance && provenance !== expectedProvenance) throw new Error("native finalizer provenance identity mismatch");
213
+ const signed = canonicalSignedArtifacts(output.signedArtifacts ?? output.signed ?? []);
214
+ if (expectedSignedArtifacts) {
215
+ const expected = canonicalSignedArtifacts(expectedSignedArtifacts);
216
+ if (JSON.stringify(signed) !== JSON.stringify(expected)) throw new Error("native finalizer signed artifact identity mismatch");
217
+ }
218
+ if (expectedPackageIdentity) {
219
+ const actual = canonicalPackageIdentity(output.packageIdentity ?? output.package ?? {});
220
+ const expected = canonicalPackageIdentity(expectedPackageIdentity);
221
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("native finalizer package identity mismatch");
222
+ }
223
+ const archiveValue = output.archiveSha256 ?? output.archive?.sha256 ?? output.archive?.digest;
224
+ if (requireArchive && !archiveValue) throw new Error("native finalizer output must bind final archive digest");
225
+ const archiveSha256 = archiveValue ? normalizeDigest(archiveValue, "archiveSha256") : undefined;
226
+ if (expectedArchiveSha256 && archiveSha256 !== normalizeDigest(expectedArchiveSha256, "expected archiveSha256")) {
227
+ throw new Error("native finalizer archive digest mismatch");
228
+ }
229
+ if (output.archive?.path != null && PLACEHOLDER.test(String(output.archive.path))) throw new Error("native finalizer archive path is a placeholder");
230
+ return { ...output, provenance, signedArtifacts: signed, ...(archiveSha256 ? { archiveSha256 } : {}) };
231
+ }
232
+
233
+ export const validateNativeFinalization = validateNativeFinalizationOutput;
234
+ export const mintRightKitProvenance = mintNativeProvenance;
235
+
236
+ export function readNativeFinalizationOutput(file) {
237
+ if (!file) throw new TypeError("native finalizer output path is required");
238
+ if (!existsSync(file)) throw new Error(`native finalizer output missing: ${file}`);
239
+ let output;
240
+ try { output = JSON.parse(readFileSync(file, "utf8")); } catch (error) { throw new Error(`native finalizer output is not valid JSON: ${file}: ${error.message}`); }
241
+ return validateNativeFinalizationOutput(output);
242
+ }
243
+
244
+ export const finalizeNativeRelease = createNativeFinalizationReceipt;
245
+
246
+ export function resolveNativeFinalizerConfig(nativeAssembly) {
247
+ const finalizer = nativeAssembly?.finalizer;
248
+ if (!finalizer) return undefined;
249
+ if (typeof finalizer === "string") return { cmd: finalizer, args: [] };
250
+ if (!finalizer || typeof finalizer !== "object" || Array.isArray(finalizer)) throw new Error("nativeAssembly.finalizer must be a command object");
251
+ const command = finalizer.command ?? finalizer;
252
+ if (!command?.cmd || typeof command.cmd !== "string" || !Array.isArray(command.args ?? [])) throw new Error("nativeAssembly.finalizer must declare cmd and args");
253
+ return {
254
+ ...finalizer,
255
+ cmd: command.cmd,
256
+ args: [...(command.args ?? [])].map(String),
257
+ output: finalizer.output ?? finalizer.receipt ?? finalizer.receiptPath ?? finalizer.outputFile,
258
+ };
259
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.70",
3
+ "version": "0.2.71",
4
4
  "description": "Portable Right Suite release CLI/SDK: native-host signed installers, updater artifacts, hardening, immutable GitHub Release upload, and add-on adoption.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -17,13 +17,6 @@
17
17
  "!standalone-clone-evidence.json"
18
18
  ],
19
19
  "sideEffects": false,
20
- "scripts": {
21
- "test": "node --test --test-concurrency=1 --test-force-exit *.test.mjs",
22
- "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
23
- "prepublishOnly": "pnpm test && pnpm test:registry-parity",
24
- "doctor:all": "node --test right-suite-contract.test.mjs",
25
- "verify:standalone": "node standalone-clone-verify.mjs"
26
- },
27
20
  "publishConfig": {
28
21
  "registry": "https://registry.npmjs.org/",
29
22
  "access": "public"
@@ -33,5 +26,10 @@
33
26
  "url": "git+https://github.com/bogusyogi/claude.git",
34
27
  "directory": "tools/rightkit/packages/release"
35
28
  },
36
- "packageManager": "pnpm@11.24.0"
37
- }
29
+ "scripts": {
30
+ "test": "node --test --test-concurrency=1 --test-force-exit *.test.mjs",
31
+ "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
32
+ "doctor:all": "node --test right-suite-contract.test.mjs",
33
+ "verify:standalone": "node standalone-clone-verify.mjs"
34
+ }
35
+ }
package/preflight.mjs CHANGED
@@ -170,6 +170,8 @@ export function collectPreflight({
170
170
  version,
171
171
  configPath,
172
172
  cargoLockPaths = [],
173
+ nativeLayout,
174
+ targetLink,
173
175
  minFreeGb = 25,
174
176
  env = process.env,
175
177
  } = {}) {
@@ -177,7 +179,8 @@ export function collectPreflight({
177
179
  const windows = platform === "win" || platform === "win32";
178
180
 
179
181
  if (appRoot) {
180
- const target = path.join(appRoot, "src-tauri", "target");
182
+ const target = targetLink ?? nativeLayout?.targetLink ?? path.join(appRoot, "src-tauri", "target");
183
+ const targetLabel = nativeLayout?.targetPrefix || "src-tauri/target/";
181
184
  let entry = null;
182
185
  try {
183
186
  entry = lstatSync(target);
@@ -186,7 +189,7 @@ export function collectPreflight({
186
189
  }
187
190
  checks.push(
188
191
  !entry || entry.isSymbolicLink()
189
- ? ok("target-bridge", entry ? "src-tauri/target is a symbolic link" : "src-tauri/target is ready for the shared cache bridge")
192
+ ? ok("target-bridge", entry ? `${targetLabel} is a symbolic link` : `${targetLabel} is ready for the shared cache bridge`)
190
193
  // A real directory here is not corruption on a broker-managed host: the
191
194
  // build broker owns CARGO_TARGET_DIR and this is its own output (or a
192
195
  // leftover from before it took ownership), not Cache V2's bridge target.
@@ -210,7 +213,7 @@ export function collectPreflight({
210
213
  ? fail(
211
214
  "version",
212
215
  `${app} ${version} is already sealed at ${sealedDir}; right-release will refuse to rebuild it`,
213
- `bump to ${sealed.suggestion} in package.json, src-tauri/tauri.conf.json and src-tauri/Cargo.toml, then re-run the legal notices generator`,
216
+ `bump to ${sealed.suggestion} in package.json and ${nativeLayout?.manifestPrefix ?? "src-tauri/"}Cargo.toml, then re-run the legal notices generator`,
214
217
  )
215
218
  : ok("version", `${version} is free to build`),
216
219
  );
@@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process';
4
4
  import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { basename, join } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
8
 
9
9
  const packageRoot = fileURLToPath(new URL('.', import.meta.url));
10
10
 
@@ -72,7 +72,7 @@ export async function verifyRegistryParity({ allowUnpublished = false, fetchImpl
72
72
  }
73
73
  }
74
74
 
75
- if (import.meta.url === `file://${process.argv[1]}`) {
75
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
76
76
  verifyRegistryParity({ allowUnpublished: process.argv.includes('--allow-unpublished') })
77
77
  .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`))
78
78
  .catch((error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
+ import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
4
5
 
5
6
  function git(cwd, args) {
6
7
  const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
@@ -53,12 +54,18 @@ export function resolveConfiguredBuildInputs(config, target, label = "release co
53
54
  }
54
55
 
55
56
  export function resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target, env = process.env }) {
57
+ const nativeLayout = resolveNativeCargoLayout({
58
+ appRoot,
59
+ config,
60
+ requireManifest: true,
61
+ requireLock: true,
62
+ });
56
63
  const requiredInputs = [
57
64
  configPath,
58
65
  path.join(appRoot, "package.json"),
59
66
  path.join(appRoot, "pnpm-lock.yaml"),
60
- path.join(appRoot, "src-tauri", "Cargo.toml"),
61
- path.join(appRoot, "src-tauri", "Cargo.lock"),
67
+ nativeLayout.manifestPath,
68
+ nativeLayout.lockPath,
62
69
  ...(target?.preflight?.files ?? []).map((file) => path.resolve(appRoot, expandEnv(file, env))),
63
70
  ].filter((file, index, files) => files.indexOf(file) === index);
64
71
  for (const file of requiredInputs) {
@@ -77,6 +84,7 @@ export function resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, confi
77
84
 
78
85
  return {
79
86
  requiredInputs,
87
+ nativeLayout,
80
88
  buildInputs: {
81
89
  include: configured.include.map(qualify),
82
90
  exclude: (configured.exclude ?? []).map(qualify),
package/release-state.mjs CHANGED
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { readCachePolicy, resolveCacheLayout } from "./cache-policy.mjs";
6
+ import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
6
7
 
7
8
  export function cacheFingerprint({ cargoLockSha256, rustc, target, architecture, features = [] }) {
8
9
  const payload = JSON.stringify({
@@ -15,14 +16,16 @@ export function cacheFingerprint({ cargoLockSha256, rustc, target, architecture,
15
16
  return createHash("sha256").update(payload).digest("hex").slice(0, 16);
16
17
  }
17
18
 
18
- export function resolveReleaseLayout({ repoRoot, configPath }) {
19
+ export function resolveReleaseLayout({ repoRoot, configPath, config, nativeLayout }) {
19
20
  const resolvedRepoRoot = path.resolve(repoRoot);
20
21
  const appRoot = path.dirname(path.resolve(configPath));
22
+ const resolvedNativeLayout = nativeLayout ?? resolveNativeCargoLayout({ appRoot, config });
21
23
  return {
22
24
  repoRoot: resolvedRepoRoot,
23
25
  appRoot,
24
26
  vaultRoot: path.join(resolvedRepoRoot, ".right-release"),
25
- targetLink: path.join(appRoot, "src-tauri", "target"),
27
+ targetLink: resolvedNativeLayout.targetLink,
28
+ nativeLayout: resolvedNativeLayout,
26
29
  };
27
30
  }
28
31
 
package/release.mjs CHANGED
@@ -9,6 +9,14 @@ import { validateRightKitCargoContract } from "./cargo-contract.mjs";
9
9
  import { assertQaBackdoorContract } from "./qa-contract.mjs";
10
10
  import { assertLegalReleaseContract } from "./legal-contract.mjs";
11
11
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
12
+ import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
13
+ import {
14
+ canonicalPackageIdentity,
15
+ createNativeFinalizationReceipt,
16
+ mintNativeProvenance,
17
+ resolveNativeFinalizerConfig,
18
+ validateNativeFinalizationOutput,
19
+ } from "./native-release-finalization.mjs";
12
20
  import { collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
13
21
  import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
14
22
  import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
@@ -92,6 +100,9 @@ if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<m
92
100
 
93
101
  const root = path.dirname(configPath);
94
102
  const workdir = path.resolve(root, config.workdir ?? ".");
103
+ const nativeLayout = resolveNativeCargoLayout({ appRoot: root, config });
104
+ const nativeAssembly = config.nativeAssembly;
105
+ const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
95
106
  await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
96
107
  validateRightKitCargoContract(root, RIGHTKIT_CARGO_ALLOWED, config.app ?? path.basename(root));
97
108
  const target = config.targets?.[opts.platform];
@@ -118,7 +129,7 @@ if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.pl
118
129
  * Assemble preflight inputs from the app's own files. Kept here (not in
119
130
  * preflight.mjs) so the check module stays free of release.mjs's config shape.
120
131
  */
121
- function releasePreflight({ config, configPath, root, repoRoot, platform }) {
132
+ function releasePreflight({ config, configPath, root, repoRoot, platform, nativeLayout: suppliedNativeLayout = nativeLayout }) {
122
133
  let version;
123
134
  try {
124
135
  version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version;
@@ -128,10 +139,9 @@ function releasePreflight({ config, configPath, root, repoRoot, platform }) {
128
139
  // Every Cargo.lock the build compiles from; a vendored-OpenSSL edge in any of
129
140
  // them means the build needs a real Windows Perl.
130
141
  const cargoLockPaths = [
131
- path.join(root, "src-tauri", "Cargo.lock"),
132
- path.join(root, "Cargo.lock"),
142
+ ...(suppliedNativeLayout?.lockCandidates ?? []),
133
143
  ...(config.cargoLocks ?? []).map((entry) => path.resolve(root, entry)),
134
- ];
144
+ ].filter((entry, index, entries) => entries.indexOf(entry) === index);
135
145
  return collectPreflight({
136
146
  platform,
137
147
  appRoot: root,
@@ -140,11 +150,13 @@ function releasePreflight({ config, configPath, root, repoRoot, platform }) {
140
150
  version,
141
151
  configPath,
142
152
  cargoLockPaths,
153
+ nativeLayout: suppliedNativeLayout,
154
+ targetLink: suppliedNativeLayout?.targetLink,
143
155
  });
144
156
  }
145
157
 
146
158
  if (opts.doctor) {
147
- const { buildInputs } = resolveReleaseBuildInputs({
159
+ const { buildInputs, nativeLayout: resolvedNativeLayout } = resolveReleaseBuildInputs({
148
160
  repoRoot: doctorInvocation.repoRoot,
149
161
  appRoot: root,
150
162
  configPath,
@@ -174,7 +186,7 @@ if (opts.doctor) {
174
186
 
175
187
  // Config above, MACHINE below. Printing config never told anyone whether this
176
188
  // box could finish a build; these checks do.
177
- const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform });
189
+ const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout });
178
190
  console.log("preflight:");
179
191
  console.log(formatPreflight(checks));
180
192
  const failures = preflightFailures(checks);
@@ -200,6 +212,7 @@ if (!opts.skipChecks) {
200
212
  for (const script of config.checks ?? []) await runPackageScript(config.packageManager, script, workdir);
201
213
  }
202
214
 
215
+ let nativeFinalization = null;
203
216
  let bundleMarkerReceipts = [];
204
217
  if (opts.platform === "win") {
205
218
  const rawFiles = target.sign.prePackageFiles.map((p) => path.resolve(root, p));
@@ -215,6 +228,29 @@ if (opts.platform === "win") {
215
228
  console.log(`right-release: bundle marker ${receipt.bundle} ${receipt.alreadyPatched ? "already applied" : "applied"} at offset ${receipt.offset} in ${path.basename(receipt.file)}`);
216
229
  }
217
230
  await signWindows(rawFiles, "raw-exe", root);
231
+ if (nativeAssembly?.packageHook && !hasNativeTargetSpecificPrePackage(target, nativeAssembly.packageHook)) {
232
+ await runCommand(nativeAssembly.packageHook, root);
233
+ }
234
+ if (nativeFinalizer) {
235
+ nativeFinalization = await runNativeFinalizer({
236
+ finalizer: nativeFinalizer,
237
+ nativeAssembly,
238
+ target,
239
+ root,
240
+ signedFiles: rawFiles,
241
+ });
242
+ }
243
+ } else if (nativeAssembly) {
244
+ if (nativeAssembly.packageHook) await runCommand(nativeAssembly.packageHook, root);
245
+ if (nativeFinalizer) {
246
+ nativeFinalization = await runNativeFinalizer({
247
+ finalizer: nativeFinalizer,
248
+ nativeAssembly,
249
+ target,
250
+ root,
251
+ signedFiles: target.sign?.prePackageFiles ?? target.sign?.files ?? [],
252
+ });
253
+ }
218
254
  }
219
255
 
220
256
  await runCommand(command, root);
@@ -223,6 +259,8 @@ for (const rel of target.artifacts ?? []) {
223
259
  await mustExist(path.resolve(root, rel), `missing release artifact: ${rel}`);
224
260
  }
225
261
 
262
+ if (nativeFinalization) await completeNativeFinalization(nativeFinalization, { root, target, nativeAssembly });
263
+
226
264
  if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT) {
227
265
  const files = target.sign.files.map((p) => path.resolve(root, p));
228
266
  for (const file of files) await mustExist(file, `missing signing artifact: ${file}`);
@@ -311,13 +349,180 @@ async function mustExist(file, message) {
311
349
  await access(file).catch(() => fail(message));
312
350
  }
313
351
 
352
+ async function runNativeFinalizer({ finalizer, nativeAssembly, target, root, signedFiles }) {
353
+ if (!finalizer?.cmd) fail(`${config.app} ${opts.platform} nativeAssembly.finalizer must declare a command`);
354
+ const signedArtifacts = nativeSignedArtifacts(signedFiles, root);
355
+ const packageIdentity = nativePackageIdentity({ nativeAssembly, target });
356
+ const provenance = mintNativeProvenance({
357
+ app: config.app,
358
+ version: config.version,
359
+ platform: opts.platform,
360
+ architecture: target.selectedArchitecture ?? target.architecture ?? nativeAssembly?.architecture ?? process.arch,
361
+ targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
362
+ signedArtifacts,
363
+ packageIdentity,
364
+ });
365
+ const outputPath = nativeFinalizerOutputPath({ finalizer, target, root });
366
+ const finalizerCommand = {
367
+ ...finalizer,
368
+ args: finalizer.args.map((arg) => expandFinalizerToken(arg, { provenance, outputPath, packageIdentity })),
369
+ };
370
+ const stdout = await runCommand(finalizerCommand, root, {
371
+ captureStdout: true,
372
+ env: {
373
+ RIGHT_RELEASE_NATIVE_PROVENANCE: provenance,
374
+ RIGHT_RELEASE_NATIVE_FINALIZATION_RECEIPT: outputPath,
375
+ RIGHT_RELEASE_NATIVE_PACKAGE_IDENTITY: JSON.stringify(packageIdentity),
376
+ RIGHT_RELEASE_NATIVE_SIGNED_ARTIFACTS: JSON.stringify(signedArtifacts),
377
+ },
378
+ });
379
+ if (opts.dryRun) return { outputPath, provenance, signedArtifacts, packageIdentity, output: null };
380
+
381
+ let output = null;
382
+ if (existsSync(outputPath)) {
383
+ try { output = JSON.parse(readFileSync(outputPath, "utf8")); } catch (error) { fail(`native finalizer output is not valid JSON: ${outputPath}: ${error.message}`); }
384
+ } else if (stdout?.trim()) {
385
+ try { output = JSON.parse(stdout); } catch { fail(`native finalizer output missing: ${outputPath}`); }
386
+ } else {
387
+ fail(`native finalizer output missing: ${outputPath}`);
388
+ }
389
+ output = enrichNativeFinalizerOutput(output, root);
390
+ try {
391
+ output = validateNativeFinalizationOutput(output, { expectedProvenance: provenance });
392
+ } catch (error) {
393
+ fail(`invalid native finalizer output: ${error.message}`);
394
+ }
395
+ return { outputPath, provenance, signedArtifacts, packageIdentity, output };
396
+ }
397
+
398
+ function enrichNativeFinalizerOutput(output, root) {
399
+ if (output?.provenance) return output;
400
+ if (output?.runtime?.provenance) return { ...output, provenance: output.runtime.provenance };
401
+ if (!output?.output) return output;
402
+ const manifestPath = path.join(path.resolve(root, output.output), "share", "legion", "release.json");
403
+ if (!existsSync(manifestPath)) return output;
404
+ try {
405
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
406
+ const provenance = manifest.runtime?.provenance;
407
+ if (provenance) return { ...output, provenance };
408
+ } catch {
409
+ return output;
410
+ }
411
+ return output;
412
+ }
413
+
414
+ async function completeNativeFinalization(finalization, { root, target, nativeAssembly }) {
415
+ if (opts.dryRun) {
416
+ console.log(`dry-run: native finalization receipt binds archive identity at ${finalization.outputPath}`);
417
+ return;
418
+ }
419
+ const archivePath = nativeArchiveCandidates({ root, target, nativeAssembly }).find((file) => existsSync(file));
420
+ if (!archivePath) fail(`${config.app} ${opts.platform} native finalization cannot find final archive`);
421
+ let receipt;
422
+ try {
423
+ receipt = createNativeFinalizationReceipt({
424
+ app: config.app,
425
+ version: config.version,
426
+ platform: opts.platform,
427
+ architecture: target.selectedArchitecture ?? target.architecture ?? nativeAssembly?.architecture ?? process.arch,
428
+ targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
429
+ signedArtifacts: finalization.signedArtifacts,
430
+ packageIdentity: finalization.packageIdentity,
431
+ archivePath,
432
+ finalizerOutput: finalization.output,
433
+ root,
434
+ });
435
+ } catch (error) {
436
+ fail(`native finalization archive binding failed: ${error.message}`);
437
+ }
438
+ mkdirSync(path.dirname(finalization.outputPath), { recursive: true });
439
+ writeFileSync(finalization.outputPath, `${JSON.stringify(receipt, null, 2)}\n`);
440
+ console.log(`right-release: native finalization verified ${receipt.provenance} archive=${receipt.archiveSha256}`);
441
+ }
442
+
443
+ function nativeSignedArtifacts(files, root) {
444
+ const values = Array.isArray(files) ? files : [];
445
+ return values.map((value) => {
446
+ const declared = typeof value === "string" ? value : value?.path ?? value?.file;
447
+ if (!declared) fail("native finalizer signed artifact path is missing");
448
+ const absolute = path.resolve(root, declared);
449
+ if (opts.dryRun) return { path: path.relative(root, absolute).replaceAll("\\", "/") };
450
+ if (!existsSync(absolute)) fail(`native finalizer signed artifact missing: ${absolute}`);
451
+ return {
452
+ path: path.relative(root, absolute).replaceAll("\\", "/"),
453
+ sha256: createHash("sha256").update(readFileSync(absolute)).digest("hex"),
454
+ sizeBytes: readFileSync(absolute).length,
455
+ };
456
+ });
457
+ }
458
+
459
+ function nativePackageIdentity({ nativeAssembly, target }) {
460
+ const declared = nativeAssembly?.packageIdentity ?? target.packageIdentity;
461
+ if (declared) return canonicalPackageIdentity(declared);
462
+ const artifacts = [
463
+ ...(target.artifacts ?? []),
464
+ ...(target.installer?.artifacts ?? []).map((artifact) => artifact.file),
465
+ ...(target.updater?.artifacts ?? []).map((artifact) => artifact.file),
466
+ ].filter(Boolean);
467
+ return canonicalPackageIdentity({
468
+ kind: target.packageKind ?? "release",
469
+ platform: opts.platform,
470
+ architecture: target.selectedArchitecture ?? target.architecture ?? nativeAssembly?.architecture ?? process.arch,
471
+ ...(artifacts.length ? { artifacts } : {}),
472
+ });
473
+ }
474
+
475
+ function nativeArchiveCandidates({ root, target, nativeAssembly }) {
476
+ const declared = [
477
+ nativeAssembly?.archive,
478
+ nativeAssembly?.archivePath,
479
+ nativeAssembly?.packageIdentity?.path,
480
+ target.archive,
481
+ target.archivePath,
482
+ ...(target.artifacts ?? []),
483
+ ...(target.installer?.artifacts ?? []).map((artifact) => artifact.file),
484
+ ...(target.updater?.artifacts ?? []).map((artifact) => artifact.file),
485
+ ].filter((value) => typeof value === "string" && value.trim());
486
+ return [...new Set(declared.map((value) => path.resolve(root, value)))];
487
+ }
488
+
489
+ function nativeFinalizerOutputPath({ finalizer, target, root }) {
490
+ const declared = finalizer.output
491
+ ?? target.evidence?.provenance
492
+ ?? target.provenance
493
+ ?? `.right-release/receipts/native-finalization-${opts.platform}.json`;
494
+ return path.resolve(root, declared);
495
+ }
496
+
497
+ function expandFinalizerToken(value, { provenance, outputPath, packageIdentity }) {
498
+ const replacements = {
499
+ "{{provenance}}": provenance,
500
+ "{provenance}": provenance,
501
+ "${RIGHT_RELEASE_NATIVE_PROVENANCE}": provenance,
502
+ "$RIGHT_RELEASE_NATIVE_PROVENANCE": provenance,
503
+ "{{receipt}}": outputPath,
504
+ "{receipt}": outputPath,
505
+ "${RIGHT_RELEASE_NATIVE_FINALIZATION_RECEIPT}": outputPath,
506
+ "$RIGHT_RELEASE_NATIVE_FINALIZATION_RECEIPT": outputPath,
507
+ "{{packageIdentity}}": JSON.stringify(packageIdentity),
508
+ "{packageIdentity}": JSON.stringify(packageIdentity),
509
+ };
510
+ return Object.entries(replacements).reduce((result, [token, replacement]) => result.replaceAll(token, replacement), String(value));
511
+ }
512
+
513
+ function hasNativeTargetSpecificPrePackage(target, packageHook) {
514
+ if (!target?.prePackage || !packageHook || target.prePackage.cmd !== packageHook.cmd) return false;
515
+ const args = target.prePackage.args ?? [];
516
+ return args.some((arg) => ["--platform", "--architecture", "--target", "--out"].includes(arg));
517
+ }
518
+
314
519
  // Post-release target/ hygiene: `cargo sweep --installed` deletes artifacts left
315
520
  // by toolchains rustup no longer has (each update orphans a multi-GB pile per
316
521
  // app) and never touches anything the current toolchains produced. Runs after
317
522
  // publish so it cannot race an artifact, and is best-effort: a missing
318
523
  // cargo-sweep or a sweep failure must never fail a release.
319
524
  function sweepStaleRustArtifacts() {
320
- const candidates = [...new Set([root, workdir].flatMap((base) => [base, path.join(base, "src-tauri")]))];
525
+ const candidates = [...new Set([root, workdir, nativeLayout.manifestDir, nativeLayout.workspaceRoot])];
321
526
  const projects = candidates.filter((dir) => existsSync(path.join(dir, "target")));
322
527
  for (const dir of projects) {
323
528
  if (opts.dryRun) {
@@ -348,10 +553,11 @@ async function runPackageScript(pm, script, cwd) {
348
553
  await run(pm, ["run", script], cwd);
349
554
  }
350
555
 
351
- async function runCommand(command, root) {
556
+ async function runCommand(command, root, { env = {}, captureStdout = false } = {}) {
352
557
  const cwd = path.resolve(root, command.cwd ?? ".");
353
- await run(command.cmd, command.args ?? [], cwd, await commandEnv(command, root), {
558
+ return run(command.cmd, command.args ?? [], cwd, { ...(await commandEnv(command, root)), ...env }, {
354
559
  ...command,
560
+ captureStdout,
355
561
  timeoutMs: commandTimeoutMs(command),
356
562
  });
357
563
  }
@@ -550,7 +756,7 @@ function run(cmd, runArgs, cwd, env = {}, options = {}) {
550
756
  const timeout = options.timeoutMs ? ` timeout=${options.timeoutMs}ms` : "";
551
757
  const tier = opts.tier ? `RIGHT_RELEASE_TIER=${opts.tier} ` : "";
552
758
  console.log(`dry-run: (${cwd}) ${tier}${printable}${timeout}`);
553
- return Promise.resolve();
759
+ return Promise.resolve("");
554
760
  }
555
761
  const started = Date.now();
556
762
  return new Promise((resolve) => {
@@ -558,10 +764,12 @@ function run(cmd, runArgs, cwd, env = {}, options = {}) {
558
764
  const child = spawn(cmd, runArgs, {
559
765
  cwd,
560
766
  env: { ...process.env, ...releaseEnv, ...env },
561
- stdio: "inherit",
767
+ stdio: options.captureStdout ? ["inherit", "pipe", "inherit"] : "inherit",
562
768
  shell: useShell,
563
769
  windowsHide: true,
564
770
  });
771
+ let stdout = "";
772
+ if (options.captureStdout) child.stdout?.on("data", (chunk) => { stdout += String(chunk); });
565
773
  const timer = options.timeoutMs
566
774
  ? setTimeout(() => {
567
775
  killProcessTree(child.pid);
@@ -579,7 +787,7 @@ function run(cmd, runArgs, cwd, env = {}, options = {}) {
579
787
  if (timer) clearTimeout(timer);
580
788
  console.log(`right-release step: ${printable} (${Date.now() - started}ms)`);
581
789
  if (code !== 0) process.exit(code ?? 1);
582
- resolve();
790
+ resolve(options.captureStdout ? stdout : "");
583
791
  });
584
792
  });
585
793
  }
@@ -22,7 +22,7 @@
22
22
  "@rightkit/logs": "0.1.4",
23
23
  "@rightkit/platform-ui": "0.1.1",
24
24
  "@rightkit/qa": "0.2.1",
25
- "@rightkit/release": "0.2.70",
25
+ "@rightkit/release": "0.2.71",
26
26
  "@rightkit/tauri": "0.1.1",
27
27
  "@rightkit/updates": "0.2.4"
28
28
  },
@@ -65,7 +65,8 @@
65
65
  "0.2.66",
66
66
  "0.2.67",
67
67
  "0.2.68",
68
- "0.2.69"
68
+ "0.2.69",
69
+ "0.2.70"
69
70
  ],
70
71
  "@rightkit/qa": [
71
72
  "0.1.0",