@rightkit/release 0.6.24 → 0.6.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +11 -35
  2. package/android-dev.mjs +2 -1
  3. package/app-check.mjs +1 -2
  4. package/app-closure.mjs +3 -2
  5. package/assembled-app.mjs +11 -3
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +6 -0
  8. package/bin/rightkit-gate.mjs +83 -26
  9. package/build-helper-trace.mjs +264 -0
  10. package/build-helpers-command.mjs +184 -0
  11. package/build-launch.mjs +11 -75
  12. package/build-release.mjs +10 -11
  13. package/cargo-profile-contract.mjs +19 -29
  14. package/cargo-target-shim.mjs +31 -0
  15. package/cli/right-release.mjs +5 -7
  16. package/create-mac-updater.mjs +4 -0
  17. package/cross-targets.mjs +94 -0
  18. package/deterministic-scans.mjs +310 -20
  19. package/development.mjs +8 -8
  20. package/freshness-census.mjs +1 -1
  21. package/gate-stages.mjs +1 -1
  22. package/install-assembled-app.mjs +1 -1
  23. package/install-tauri-authenticode.mjs +3 -3
  24. package/local-publish.mjs +27 -5
  25. package/mac-package-recipe.mjs +55 -2
  26. package/managed-cargo.mjs +27 -1
  27. package/managed-roots.mjs +12 -4
  28. package/package.json +1 -5
  29. package/pipeline-admission.mjs +1 -1
  30. package/pipeline-receipts.mjs +302 -302
  31. package/provenance-verify.mjs +3 -1
  32. package/qa-build-features.mjs +11 -2
  33. package/release-qa-audit.mjs +9 -27
  34. package/release-qa-command.mjs +23 -64
  35. package/release-qa-fixtures.mjs +9 -5
  36. package/release-qa-floor.json +0 -1
  37. package/release-qa-policy.mjs +9 -10
  38. package/release-qa-receipt.mjs +6 -44
  39. package/release-source-identity.mjs +1 -12
  40. package/release-switch-registry.json +4 -1
  41. package/rightkit-registry-resolver.mjs +35 -3
  42. package/rightkit-versions.json +2 -2
  43. package/sign-windows.mjs +1 -1
  44. package/snapshot-broker.mjs +33 -8
  45. package/snapshot-metadata.mjs +32 -0
  46. package/snapshot-request.mjs +6 -3
  47. package/stage-sidecar.mjs +28 -11
  48. package/suite-signing-policy.mjs +8 -58
  49. package/templates/cargo-profiles.toml +6 -25
  50. package/test-command.mjs +40 -140
  51. package/updater-shipped-key.mjs +26 -0
  52. package/web-build.mjs +29 -4
  53. package/qa-journeys-prebuilt-run.mjs +0 -18
  54. package/qa-journeys-prebuilt.mjs +0 -148
  55. package/qa-layout.mjs +0 -229
  56. package/qa-snapshot-run.mjs +0 -53
  57. package/qa-snapshot.mjs +0 -164
  58. package/sign-qa-bundle.mjs +0 -210
  59. package/suite-signing.mjs +0 -259
package/build-release.mjs CHANGED
@@ -27,10 +27,9 @@ import { sccacheRefusal } from "./admission-host.mjs";
27
27
  import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import { dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
30
- import { assertInsideSource, assertReleaseSource, gitRootOf, isSnapshotSource, qaAdmissionSourceOptions, resolveReleaseSource, sourceRootOf } from "./release-source-identity.mjs";
30
+ import { assertInsideSource, assertReleaseSource, gitRootOf, isSnapshotSource, resolveReleaseSource, sourceRootOf } from "./release-source-identity.mjs";
31
31
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
32
32
  import { ArtifactPathError, assertSplitCommandPaths, boundTargetsEvidence, releaseCommandSteps, resolveArtifactPath as resolveSplitArtifactPath, splitArtifactContext } from "./release-artifact-path.mjs";
33
- import { admitSourceQa } from "./release-qa-audit.mjs";
34
33
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
35
34
  import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
36
35
  import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
@@ -361,11 +360,6 @@ try {
361
360
  for (const check of preflight) {
362
361
  if (check.status !== "ok") console.error(`[right-release] preflight ${check.name}: ${check.detail}`);
363
362
  }
364
- // Hardening step 4: source QA is recorded, not enforced. A missing or rejected receipt warns loudly and the outcome
365
- // is sealed into the manifest (`qa.status`) for `right-release qa audit`. Dev lanes (install-app --dev) never reach this file.
366
- // A split compares the source QA receipt with the SELECTED commit (its tree, repository identity and lockfile closure from its objects), not HEAD.
367
- const qaAdmission = admitSourceQa({ repoRoot: layout.repoRoot, releaseId, platform, configName: path.relative(process.cwd(), configPath) || path.basename(configPath), releaseConfigSha256: hashFile(configPath), ...qaAdmissionSourceOptions(releaseSource) });
368
-
369
363
  // A tool being INSTALLED is not the same as it being the one PATH picks. The
370
364
  // vendored-OpenSSL build shells out to `perl`, and under Git Bash/MSYS that
371
365
  // resolves to MSYS Perl, which cannot configure OpenSSL. Put the real Windows
@@ -566,7 +560,13 @@ try {
566
560
  if (linked.action === "moved-aside") console.error(`[right-release] hosted CI: moved existing ${targetLink} aside to ${linked.aside}`);
567
561
  }
568
562
  // A Cargo-less native iOS app installs Node dependencies only when it has a package.json.
569
- if (nativeIos ? hasPackageJson : resolveProjectType({ appRoot, config, target }) !== "swiftpm" || config.packageManager) await runLocalStep(() => runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, heavyDecisionOfRun?.localSteps ? { ...env, RIGHTSUITE_HEAVY_WORK_OWNER: "1" } : env, cacheTarget, path.join(stateRoot, "stall-install.log")));
563
+ const needsNodeInstall = nativeIos ? hasPackageJson : resolveProjectType({ appRoot, config, target }) !== "swiftpm" || config.packageManager;
564
+ // rightkit#1443.3: in a gate split the snapshot's node_modules were already installed by the gate, offline, from the same lockfile; a
565
+ // second install here would run inside the snapshot around the broker (which refuses it). It is skipped; a snapshot without them is refused.
566
+ if (needsNodeInstall && splitSource) {
567
+ if (!existsSync(path.join(appRoot, "node_modules"))) fail(`the gate's snapshot at ${appRoot} has no node_modules: the gate installs them before the package stage; nothing is installed here (rightkit#1443)`);
568
+ console.error(`[right-release] node_modules already installed by the gate in the snapshot; no second install (rightkit#1443)`);
569
+ } else if (needsNodeInstall) await runLocalStep(() => runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, heavyDecisionOfRun?.localSteps ? { ...env, RIGHTSUITE_HEAVY_WORK_OWNER: "1" } : env, cacheTarget, path.join(stateRoot, "stall-install.log")));
570
570
  },
571
571
  build: async () => {
572
572
  throwIfInterrupted();
@@ -602,7 +602,7 @@ try {
602
602
  });
603
603
  },
604
604
  seal: async ({ sealedDir }) => {
605
- sealRelease({ configRoot: appRoot, managedCargoTarget, artifactPaths, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields: rightKitResolution ? registryManifestFields(rightKitResolution.snapshot, RELEASE_TOOL_VERSION) : undefined, splitSealSource: splitSource ? { commit: releaseSource.commit, tree: releaseSource.tree, snapshotAppRoot: appRoot, checkoutAppRoot: releaseSource.checkoutAppRoot } : null });
605
+ sealRelease({ configRoot: appRoot, managedCargoTarget, artifactPaths, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, managedRoot, stateRoot, rightkitRegistryFields: rightKitResolution ? registryManifestFields(rightKitResolution.snapshot, RELEASE_TOOL_VERSION) : undefined, splitSealSource: splitSource ? { commit: releaseSource.commit, tree: releaseSource.tree, snapshotAppRoot: appRoot, checkoutAppRoot: releaseSource.checkoutAppRoot } : null });
606
606
  verifySealedRelease(sealedDir);
607
607
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
608
608
  checkpoint(stateRoot, "sealed");
@@ -695,7 +695,7 @@ function sqlCipherFeatures(text) {
695
695
  .filter((value) => /sqlcipher|openssl/i.test(value));
696
696
  }
697
697
 
698
- function sealRelease({ configRoot, managedCargoTarget, artifactPaths = null, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields, splitSealSource = null }) {
698
+ function sealRelease({ configRoot, managedCargoTarget, artifactPaths = null, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, managedRoot, stateRoot, rightkitRegistryFields, splitSealSource = null }) {
699
699
  // Every app's release config names installer/updater artifacts
700
700
  // relative to its resolved native target. On a broker-managed host that
701
701
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
@@ -869,7 +869,6 @@ function sealRelease({ configRoot, managedCargoTarget, artifactPaths = null, and
869
869
  // The exact registry resolution (identities, digests, registries, policy revision, observation time, observed latest)
870
870
  // this release was built against. Upload and resume consume it; neither resolves again.
871
871
  ...rightkitRegistryFields,
872
- qa: qaAdmission,
873
872
  // DevTools allowance (owner's fixed design): { mode: "denied" } for every app; content-only only for an allow-listed app.
874
873
  devtools: devtoolsEvidence(resolveDevtoolsAllowance({ config, root: configRoot })),
875
874
  platform,
@@ -1,16 +1,17 @@
1
1
  // RightKit standard Cargo profile contract (Adrian, 2026-10-16; Astra-reviewed option C).
2
2
  //
3
- // Every app workspace-root Cargo.toml carries the tables of templates/cargo-profiles.toml verbatim:
4
- // - [profile.dev] and [profile.dev.package."*"] equal the template (no missing, changed or extra keys),
5
- // - panic = "unwind" in dev and release,
6
- // - no [profile.release-iterate] (and nothing that inherits it),
7
- // - no [profile.test] overrides (test inherits dev).
8
- // The same scan also flags the two source patterns the single dev profile makes wrong, because a dev
9
- // build now has debug_assertions on:
10
- // - `cfg_attr(not(debug_assertions), windows_subsystem = ...)`: an installed dev app would open a
11
- // console window on Windows. Set windows_subsystem = "windows" unconditionally.
12
- // - an environment-variable override gated by cfg(debug_assertions): move it to the app's test-mode
13
- // feature. Heuristic: a cfg(debug_assertions) attribute or cfg! within 12 lines before an env read.
3
+ // One build output and one warm cache per app per host (GATE-CONTRACT GC-A01, GC-A07). Every gate compile (check, clippy,
4
+ // rustdoc, the test compile and both installers) builds with [profile.release] for the host's own target, whatever the caller
5
+ // passed (cross-targets.mjs oneOutput); the dev installer is the release profile plus the `qa-native` feature. So no gate build
6
+ // has debug_assertions, and the release tool's scans (not debug assertions) keep the QA control server out of a release.
7
+ // Every app workspace-root Cargo.toml therefore carries:
8
+ // - [profile.release] with panic = "unwind" (templates/cargo-profiles.toml),
9
+ // - no other profile table: no [profile.dev], [profile.test] or custom profile (each would be a second output directory),
10
+ // - no [profile.release-iterate] (retired; nor anything that inherits it).
11
+ // The same scan also flags a source pattern that behaves differently between the profiles:
12
+ // - an environment-variable override gated by cfg(debug_assertions): no gate build compiles it (tests and installers
13
+ // are all release), so only a developer's own cargo run reaches it. Gate it on an explicit feature instead. Heuristic: a
14
+ // cfg(debug_assertions) attribute or cfg! within 12 lines before an env read.
14
15
  // Suppress a reviewed site with a `rightkit-allow: debug-assertions-env` comment on the cfg line or
15
16
  // the line above it.
16
17
  // The template file is the single source: this module reads it, it never copies the values.
@@ -28,12 +29,10 @@ const ENV_WINDOW_LINES = 12;
28
29
  // Never part of an app's shipped source: tests, examples, benches and vendored reference copies.
29
30
  const SOURCE_SKIP = new Set(["tests", "examples", "benches", "reference"]);
30
31
  const ENV_READ = /env::var|var_os|env::set_var|env::remove_var|\benv!\s*\(|option_env!|_env\(|env_var|env_nonempty/;
31
- const WINDOWS_SUBSYSTEM_GATED = /cfg_attr\s*\((?:(?!\]).)*?not\s*\(\s*debug_assertions\s*\)(?:(?!\]).)*?windows_subsystem/gs;
32
32
 
33
33
  export function loadCargoProfileTemplate(file = CARGO_PROFILE_TEMPLATE) {
34
34
  const profile = parse(readFileSync(file, "utf8")).profile ?? {};
35
- const { package: packages = {}, ...dev } = profile.dev ?? {};
36
- return { dev, devPackageStar: packages["*"] ?? {}, releasePanic: profile.release?.panic };
35
+ return { releasePanic: profile.release?.panic };
37
36
  }
38
37
 
39
38
  const isTable = (value) => value && typeof value === "object" && !Array.isArray(value);
@@ -54,21 +53,15 @@ export function checkManifestProfiles(manifest, template, file) {
54
53
  const findings = [];
55
54
  const finding = (rule, message) => findings.push({ rule, file, message });
56
55
  const profiles = isTable(manifest.profile) ? manifest.profile : {};
57
- const dev = isTable(profiles.dev) ? profiles.dev : undefined;
58
- const { package: devPackages, ...devRest } = dev ?? {};
59
- // Subtables of [profile.dev] are checked separately below.
60
- const devKeys = Object.fromEntries(Object.entries(devRest).filter(([, value]) => !isTable(value)));
61
- compareTable(dev ? devKeys : undefined, template.dev, "[profile.dev]", finding);
62
- compareTable(isTable(devPackages) ? devPackages["*"] : undefined, template.devPackageStar, '[profile.dev.package."*"]', finding);
63
- if (isTable(devPackages)) for (const name of Object.keys(devPackages)) if (name !== "*") finding("profile-extra", `[profile.dev.package.${show(name)}] is not in the template`);
64
- if (dev) for (const name of Object.keys(dev)) if (isTable(dev[name]) && name !== "package") finding("profile-extra", `[profile.dev.${name}] is not in the template`);
65
- // [profile.dev] panic is checked with the rest of the dev table above.
56
+ // One build output (GC-A01, GC-A07): no gate build uses any profile but release, so an app carries no other profile table.
57
+ for (const name of Object.keys(profiles)) {
58
+ if (name !== "release" && name !== RETIRED_PROFILE) finding("profile-extra", `[profile.${name}] is not allowed: every gate build uses [profile.release] for the host target (one output directory, GATE-CONTRACT GC-A07); delete the table`);
59
+ }
66
60
  if (profiles.release?.panic !== template.releasePanic) finding("panic", `[profile.release] panic must be ${show(template.releasePanic)} (found ${show(profiles.release?.panic)})`);
67
61
  if (RETIRED_PROFILE in profiles) finding("retired-profile", `[profile.${RETIRED_PROFILE}] is retired; delete it and its package overrides`);
68
62
  for (const [name, table] of Object.entries(profiles)) {
69
63
  if (isTable(table) && table.inherits === RETIRED_PROFILE) finding("retired-profile", `[profile.${name}] inherits the retired ${RETIRED_PROFILE} profile`);
70
64
  }
71
- if ("test" in profiles) finding("profile-extra", "[profile.test] overrides are not allowed; test inherits dev");
72
65
  return findings;
73
66
  }
74
67
 
@@ -149,7 +142,7 @@ function* rustFiles(dir) {
149
142
  }
150
143
  }
151
144
 
152
- // Source patterns the single dev profile makes wrong (see the header). Returns findings with a line number.
145
+ // Source patterns that behave differently between the profiles (see the header). Returns findings with a line number.
153
146
  export function scanDebugAssertionSources(root) {
154
147
  const findings = [];
155
148
  for (const file of rustFiles(root)) {
@@ -157,9 +150,6 @@ export function scanDebugAssertionSources(root) {
157
150
  if (!text.includes("debug_assertions")) continue;
158
151
  const label = path.relative(root, file).split(path.sep).join("/");
159
152
  const lineAt = (index) => text.slice(0, index).split("\n").length;
160
- for (const match of text.matchAll(WINDOWS_SUBSYSTEM_GATED)) {
161
- findings.push({ rule: "windows-subsystem", file: label, line: lineAt(match.index), message: 'cfg_attr(not(debug_assertions), windows_subsystem = ...) opens a console for installed dev apps; set windows_subsystem = "windows" unconditionally' });
162
- }
163
153
  const lines = text.split("\n");
164
154
  lines.forEach((line, index) => {
165
155
  const trimmed = line.trim();
@@ -167,7 +157,7 @@ export function scanDebugAssertionSources(root) {
167
157
  if (!/#!?\[cfg|cfg!\s*\(/.test(line)) return;
168
158
  if (line.includes(ENV_ALLOW_MARKER) || (lines[index - 1] ?? "").includes(ENV_ALLOW_MARKER)) return;
169
159
  if (ENV_READ.test(lines.slice(index, index + ENV_WINDOW_LINES + 1).join("\n"))) {
170
- findings.push({ rule: "debug-assertions-env", file: label, line: index + 1, message: "environment-variable override gated by cfg(debug_assertions); move it to the app's test-mode feature" });
160
+ findings.push({ rule: "debug-assertions-env", file: label, line: index + 1, message: "environment-variable override gated by cfg(debug_assertions): present in cargo test, absent from both installers (dev and release use [profile.release]); inject it (a config value or a test root the caller passes), never read it from the environment" });
171
161
  }
172
162
  });
173
163
  }
@@ -60,6 +60,24 @@ const ALIASES = Object.freeze({ b: "build", c: "check", t: "test", r: "run", d:
60
60
  const VERSION_PROBES = Object.freeze([["-V"], ["--version"], ["-vV"], ["-Vv"], ["--version", "--verbose"], ["-V", "--verbose"], ["-v", "-V"], ["version"]]);
61
61
  // The read-only commands the broker admits to a carried request only in carriedScanRefusal's exact shapes (snapshot-metadata.mjs).
62
62
  const CARRIED_SHAPE = new Set(["metadata", "deny", "audit", "fmt"]);
63
+ // GATE-CONTRACT GC-A11 (Adrian, 2026-11-02): a cargo call that compiles nothing never goes to the broker; in broker mode these run directly
64
+ // with the host's real toolchain cargo (map.realCargo), each into its own scratch target, never carried (rustright a2972f0 refuses them).
65
+ export const READ_ONLY_DIRECT = Object.freeze(new Set(["metadata", "fetch", "tree", "pkgid", "verify-project", "read-manifest", "locate-project", "deny", "audit", "fmt"]));
66
+ /** Whether an argv compiles nothing: a READ_ONLY_DIRECT subcommand, or `cargo package --list` (lists files, builds nothing). */
67
+ export function readOnlyCargo(args) {
68
+ const sub = cargoSubcommandOf(args);
69
+ return READ_ONLY_DIRECT.has(sub) || (sub === "package" && args.includes("--list"));
70
+ }
71
+ /** The cargo subcommand of an argv (after a +toolchain and global options), or null. */
72
+ export function cargoSubcommandOf(args) {
73
+ for (let i = args[0]?.startsWith("+") ? 1 : 0; i < args.length; i += 1) {
74
+ const w = args[i];
75
+ if (w === "--") return null;
76
+ if (GLOBAL_VALUE.has(w)) { i += 1; continue; }
77
+ if (!w.startsWith("-")) return ALIASES[w] ?? w;
78
+ }
79
+ return null;
80
+ }
63
81
  const insideOf = (child, parent) => { const rel = path.relative(parent, child); return rel === "" || !(rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)); };
64
82
  // Global options that take a value (`--color always`, `--explain E0001`); the subcommand is the first word after them.
65
83
  const GLOBAL_VALUE = new Set(["--color", "--explain"]);
@@ -306,6 +324,19 @@ export function runShim(argv, { env = process.env, cwd = process.cwd(), spawn =
306
324
  let decision;
307
325
  try { decision = decideCargoInvocation({ args: words, cwd, env, map, locate }); } catch (error) { return refuse({ code: "RK_CARGO_SHIM_ERROR", message: error.message }); }
308
326
  if (decision.refuse) return refuse(decision.refuse);
327
+ if (broker && decision.exec.workspace && readOnlyCargo(decision.exec.args) && map.realCargo) {
328
+ // GC-A11: a non-compiling call runs directly with the real toolchain cargo, writing only to its own scratch target (removed after).
329
+ let scratch;
330
+ try { scratch = fs.mkdtempSync(path.join(path.dirname(map.ledgerDir), "read-scratch-")); }
331
+ catch (error) { return refuse({ code: "RK_CARGO_SHIM_ERROR", message: `cannot create a scratch target for a read-only call (${error.message})` }); }
332
+ const { CARGO_TARGET_DIR: _bound, CARGO_BUILD_TARGET_DIR: _bound2, ...rest } = decision.exec.env ?? env; void _bound; void _bound2;
333
+ let r;
334
+ try { r = spawn(map.realCargo, decision.exec.args, { cwd, env: { ...rest, CARGO: map.realCargo, CARGO_TARGET_DIR: scratch }, stdio: "inherit" }); }
335
+ finally { try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* scratch under the wrapper's own directory */ } }
336
+ if (r.error) return refuse({ code: "RK_CARGO_SHIM_ERROR", message: `${map.realCargo} did not start (${r.error.message})` });
337
+ record({ ...base, status: "ran", direct: true, exit: r.status ?? null });
338
+ return r.status ?? (r.signal ? 128 : SHIM_REFUSAL_STATUS);
339
+ }
309
340
  if (broker && decision.exec.workspace) {
310
341
  // The `pending` record above stays this call's until the broker call finishes: its records carry the same call id.
311
342
  return runBrokerCargo({ words: decision.exec.args, cwd, env, section: map.broker, workspace: decision.exec.workspace, checkoutWorkspace: decision.exec.checkoutWorkspace, record: (entry) => record({ ...entry, id }), spawn, err });
@@ -21,7 +21,6 @@ const commands = new Map([
21
21
  ["sign-windows", "sign-windows.mjs"],
22
22
  ["sign-updater", "sign-updater.mjs"],
23
23
  ["sign-dev-artifact", "sign-dev-artifact.mjs"],
24
- ["sign-qa-bundle", "sign-qa-bundle.mjs"],
25
24
  ["sign-candidate", "sign-candidate.mjs"],
26
25
  ["engine-locate", "engine-locator.mjs"],
27
26
  ["stage-sidecar", "stage-sidecar-command.mjs"],
@@ -35,6 +34,7 @@ const commands = new Map([
35
34
  ["mirror-root-artifact", "mirror-root-artifact.mjs"],
36
35
  ["test", "test-command.mjs"],
37
36
  ["web-build", "web-build.mjs"],
37
+ ["build-helpers", "build-helpers-command.mjs"],
38
38
  ["license-notices", "npm-license-notices.mjs"],
39
39
  ["toolchain", "toolchain-swift.mjs"],
40
40
  ["github", "github-release.mjs"],
@@ -329,8 +329,6 @@ Commands:
329
329
  Fail when a built web dist/ carries a forbidden marker string or mock/fixture chunk name (dev, mock or fixture code in release)
330
330
  sign-dev-artifact --artifact <dir> --manifest <sign.json> [--metadata <payload.json>] [--evidence <path>] [--dry-run]
331
331
  Developer ID sign an existing CI dev artifact per file with fixed identifiers; never builds, notarizes or publishes (macOS)
332
- sign-qa-bundle --app <abs .app> --qa-root <abs dir> [--entitlements <abs plist>] [--out <evidence.json>]
333
- QA ONLY: ad hoc sign a bundle inside a QA scratch root, nested code first, outer last, never --deep, never the signing agent (macOS); not a release artifact
334
332
  sign-candidate --platform win|mac --artifact <dir> --candidate-receipt <receipt.json> --out <binding.json> [--manifest <sign.json>] [--publisher <subject>] [--dry-run]
335
333
  Sign the exact unsigned candidate bytes (sha256 checked against the candidate receipt) instead of rebuilding; writes a candidate-sha -> signed-sha binding receipt
336
334
  engine-locate --engine cutright|genright [--sha256 <hex>] [--json]
@@ -342,10 +340,10 @@ Commands:
342
340
  upload --platform ios --release <id> [--apply] Sealed IPA to TestFlight, then the exact build into the beta groups; dry run unless --apply (macOS)
343
341
  ios-credentials store --key-id <KEYID> --issuer <uuid> | check
344
342
  Put the App Store Connect API key (stdin), key id and issuer into the login Keychain once, or check them; never a file (macOS)
345
- qa --stage source|installed --platform mac|win [--release <id>] Run rightkit-qa (and the journeys lane when the config sets qa.journeys) and write the sealed QA receipt. Refused inside a QA run. The receipt must match the platform and release id, come from an approved runner digest, and show every release-critical journey passed
346
- Admission is per app, and per stage (source = build, installed = upload and publish), in qa-admission-policy.json: warn (default) records a refusal and continues; refuse blocks that stage. Only adrian moves an app or stage from refuse back to warn, or approves a runner
347
- qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] List source/installed QA status per release; exit 1 if an uploaded build lacks passing QA
348
- qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]
343
+ qa --stage installed --platform mac|win [--release <id>] Run rightkit-qa (and the journeys lane when the config sets qa.journeys) and write the sealed QA receipt. Refused inside a QA run. The receipt must match the platform and release id, come from an approved runner digest, and show every release-critical journey passed
344
+ Admission is per app (installed = upload and publish; there is no source stage, GATE-CONTRACT GC-A07), in qa-admission-policy.json: warn (default) records a refusal and continues; refuse blocks the publication. Only adrian moves an app from refuse back to warn, or approves a runner
345
+ qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] List installed QA status per release; exit 1 if an uploaded build lacks passing QA
346
+ qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage installed]
349
347
  Show the QA admission policy, or set an app's mode (both stages, or one with --stage)
350
348
  qa policy approve-runner --runner <file> --version <v> --platform mac|win --set-by adrian [--note <text>]
351
349
  Approve a rightkit-qa runner build for one platform: the file must report "rightkit-qa <v>" from --version and <v> must be the published version
@@ -3,6 +3,7 @@ import { spawnSync } from "./background-process.mjs";
3
3
  import { existsSync, rmSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { signMacUpdaterFiles } from "./macos-signing-session.mjs";
6
+ import { assertUpdaterMatchesShippedKey } from "./updater-shipped-key.mjs";
6
7
  import { ArtifactPathError, loadSplitArtifactContext, resolveArtifactPath, resolveArtifactWritePath } from "./release-artifact-path.mjs";
7
8
 
8
9
  const args = process.argv.slice(2);
@@ -56,6 +57,9 @@ else {
56
57
  console.log(`pnpm exec tauri signer sign ${outputPath}`);
57
58
  try { signMacUpdaterFiles([outputPath], { cwd, env }); }
58
59
  catch (error) { fail(`${error.code ? `[${error.code}] ` : ""}${error.message}`); }
60
+ // rightkit#1443: the signature must verify with the key the app ships (tauri.conf plugins.updater.pubkey), whatever key signed it.
61
+ try { assertUpdaterMatchesShippedKey({ appRoot: cwd, file: outputPath }); }
62
+ catch (error) { fail(`[MAC_UPDATER_SHIPPED_KEY] ${error.message}`); }
59
63
  }
60
64
 
61
65
  function run(command, commandArgs) {
@@ -0,0 +1,94 @@
1
+ // The three OS targets every crate is checked for on every host (Adrian, 2026-11-02: "anything run through the Mac or the Windows or
2
+ // Linux must all check for all 3"). The host builds and tests its own OS; the other two are `cargo clippy --target` checks, so code under
3
+ // cfg(target_os = "macos") or cfg(windows) fails at the gate instead of at publish.yml native verify (five crates did on 2026-11-02:
4
+ // system-monitor, local-gen, app-manager, dictation-models, credentials). Windows is checked as the GNU target: zig supplies its C
5
+ // toolchain from any host (scripts/ci/provision-zig.mjs), and cfg(windows) code is the same for both Windows ABIs.
6
+ // Shared by local-publish.mjs (unit plans and batch clippy), deterministic-scans.mjs (the gate's class B scans), stage-sidecar.mjs and
7
+ // scripts/ci/gate.mjs. Imports only node:fs and node:path.
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+
11
+ export const CROSS_TARGETS = Object.freeze({ linux: "x86_64-unknown-linux-gnu", darwin: "aarch64-apple-darwin", win32: "x86_64-pc-windows-gnu" });
12
+ /** zig's name for each cross target, for the C compiler wrappers scripts/ci/provision-zig.mjs writes. */
13
+ export const ZIG_TARGETS = Object.freeze({ "x86_64-unknown-linux-gnu": "x86_64-linux-gnu", "aarch64-apple-darwin": "aarch64-macos", "x86_64-pc-windows-gnu": "x86_64-windows-gnu" });
14
+ export const CROSS_CC_ENV = "RIGHTKIT_CROSS_CC_DIR";
15
+
16
+ /** The host's own build target, as the package stage names it: Windows builds MSVC (win-package-recipe), the Mac and Linux their native arch. */
17
+ export const HOST_TARGETS = Object.freeze({ "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "win32-x64": "x86_64-pc-windows-msvc", "win32-arm64": "aarch64-pc-windows-msvc", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu" });
18
+ export function hostTarget(platform = process.platform, arch = process.arch) {
19
+ const target = HOST_TARGETS[`${platform}-${arch}`];
20
+ if (!target) throw new Error(`no host Rust target for ${platform}-${arch}`);
21
+ return target;
22
+ }
23
+
24
+ /**
25
+ * The foreign targets every crate is checked for on `platform`: the other two of CROSS_TARGETS, always. There is no opt-out (Adrian,
26
+ * 2026-11-03): a crate that cannot be type-checked for an OS has a bug to fix (cfg-gate its OS code, or make its build script return early
27
+ * for a foreign CARGO_CFG_TARGET_OS). A `crossTargets` key in ci/gate.config.json is refused.
28
+ */
29
+ export function crossTargetsFor(entry = {}, platform = "darwin") {
30
+ if (entry?.crossTargets !== undefined || entry?.crossTargetsSkip !== undefined) throw new Error("ci/gate.config.json crossTargets/crossTargetsSkip is not allowed: every crate is checked for all three OS targets");
31
+ const native = CROSS_TARGETS[platform];
32
+ if (!native) throw new Error(`no native target for ${platform}`);
33
+ return Object.values(CROSS_TARGETS).filter((target) => target !== native);
34
+ }
35
+
36
+ /**
37
+ * The provisioned zig wrapper directory: RIGHTKIT_CROSS_CC_DIR, else `<managed data root>/zig/cross-cc` (where scripts/ci/provision-zig.mjs
38
+ * writes on a Mac or Dell), else null.
39
+ */
40
+ export function crossCcDir({ env = process.env, exists = fs.existsSync, platform = process.platform } = {}) {
41
+ if (env[CROSS_CC_ENV]) return env[CROSS_CC_ENV];
42
+ const managed = platform === "win32" ? "D:\\.rightkit-managed" : platform === "darwin" ? "/Volumes/D/.rightkit-managed" : null;
43
+ const dir = managed ? path.join(managed, "zig", "cross-cc") : null;
44
+ return dir && exists(dir) ? dir : null;
45
+ }
46
+
47
+ /** cc-rs / cmake environment pointing a target's C, C++ and archiver at the provisioned zig wrappers (none when unprovisioned). */
48
+ export function crossCompileEnv(target, dir = crossCcDir(), platform = process.platform) {
49
+ if (!dir) return undefined;
50
+ const key = target.replaceAll("-", "_");
51
+ const ext = platform === "win32" ? ".cmd" : "";
52
+ return {
53
+ [`CC_${key}`]: path.join(dir, `cc-${target}${ext}`),
54
+ [`CXX_${key}`]: path.join(dir, `cxx-${target}${ext}`),
55
+ [`AR_${key}`]: path.join(dir, `ar-${target}${ext}`),
56
+ };
57
+ }
58
+
59
+ // One build output and one warm cache per app per host (Adrian: "Flag, no flag, it should go into 1 directory"; GATE-CONTRACT GC-A01, GC-A07).
60
+ // Cargo picks the output directory from the profile (dev -> debug, release -> release) and from whether a target is named
61
+ // (target/<profile> vs target/<triple>/<profile>), so every compile the gate launches is rewritten here, whatever its caller passed:
62
+ // `--profile release` is always set (a request for any other profile, `--debug` included, is refused), and a call that names no target gets
63
+ // the host's own. A call that names another target keeps it (the cross-target check: that OS's own directory, still release).
64
+ // Non-compiling subcommands (metadata, tree, deny, audit, package --list, fmt) are returned unchanged.
65
+ const COMPILE_SUBCOMMANDS = new Set(["build", "b", "check", "c", "clippy", "rustc", "rustdoc", "doc", "d", "test", "t", "bench", "run", "r"]);
66
+ export function oneOutput(args, { platform = process.platform, arch = process.arch } = {}) {
67
+ // The subcommand is the first word after an optional leading `cargo`, `+<toolchain>` and cargo's own global options.
68
+ let at = 0;
69
+ if (args[at] === "cargo") at += 1;
70
+ while (at < args.length && (args[at].startsWith("+") || args[at].startsWith("-"))) at += ["--config", "-Z", "-C", "--color"].includes(args[at]) ? 2 : 1;
71
+ if (!COMPILE_SUBCOMMANDS.has(args[at])) return [...args];
72
+ const head = args.slice(0, at + 1); const rest = [];
73
+ const tail = args.slice(at + 1);
74
+ const sep = tail.indexOf("--");
75
+ const own = sep < 0 ? tail : tail.slice(0, sep);
76
+ // Hard block (Adrian, 2026-11-03: "i wnat all dev completely gone. all debug too ... never another, hard block"): a compile that asks for any
77
+ // profile but release is refused, never rewritten. `--release` / `-r` is release and is normalized to `--profile release`.
78
+ for (let i = 0; i < own.length; i += 1) {
79
+ const a = own[i];
80
+ if (a === "--release" || a === "-r") continue;
81
+ const asked = a === "--debug" ? "dev" : a === "--profile" ? own[i + 1] : a.startsWith("--profile=") ? a.slice("--profile=".length) : null;
82
+ if (asked !== null) {
83
+ if (asked !== "release") throw new Error(`RK_PROFILE_REFUSED: cargo ${args[at]} asks for the ${JSON.stringify(asked)} profile; only release is allowed (one build output per app, GATE-CONTRACT GC-A07)`);
84
+ if (a === "--profile") i += 1;
85
+ continue;
86
+ }
87
+ rest.push(a);
88
+ }
89
+ // GATE-CONTRACT GC-A09: tests are isolated by injection; no `test-mode` feature exists in any build (Adrian: "No, no flag").
90
+ const featureLists = rest.flatMap((a, i) => (a === "--features" || a === "-F" ? [rest[i + 1] ?? ""] : a.startsWith("--features=") ? [a.slice("--features=".length)] : []));
91
+ if (featureLists.some((list) => list.split(/[\s,]+/).some((f) => f === "test-mode" || f.endsWith("/test-mode")))) throw new Error(`RK_TEST_MODE_REFUSED: cargo ${args[at]} enables a test-mode feature; tests are isolated by injection (GATE-CONTRACT GC-A09), no build has a test mode`);
92
+ const named = rest.includes("--target") || rest.some((a) => a.startsWith("--target="));
93
+ return [...head, "--profile", "release", ...(named ? [] : ["--target", hostTarget(platform, arch)]), ...rest, ...(sep < 0 ? [] : tail.slice(sep))];
94
+ }