@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/README.md CHANGED
@@ -87,7 +87,7 @@ On desktop (non-iOS) targets the source walk covers the whole app root, so app t
87
87
  | Build (SwiftPM apps) | `swift-package` (`windows-stage`, `msix`, `mac-app`) (config schema 1: `windows.{exe,out,searchDirs,include,dependencies,publisher,assets}`, `mac.{executable,bundleId,out,frameworks,resources,entitlements}`) |
88
88
  | Install | `install-app` |
89
89
  | Publish | `publish`, `upload`, `publish-update`, `github`, `mirror-root-artifact` |
90
- | Signing | `sign-windows`, `sign-updater`, `signing-agent`, `create-mac-updater`, `sign-dev-artifact`, `sign-candidate`, `sign-qa-bundle` |
90
+ | Signing | `sign-windows`, `sign-updater`, `signing-agent`, `create-mac-updater`, `sign-dev-artifact`, `sign-candidate` |
91
91
  | Engine locator | `engine-locate --engine cutright\|genright [--sha256 <hex>] [--json]`: the verified route to the installed CutRight or GenRight engine, also importable as `locateEngine` from `engine-locator.mjs`. The path is fixed (`<LocalAppData or ~/Library/Application Support>/Orthic Labs/<App>/current/bin/<engine>`), must be a regular file (no symlink or junction), and must pass signature and identity checks (macOS: strict codesign, Developer ID Team ID, fixed Identifier, hardened runtime, not ad hoc; Windows: valid Authenticode with the Orthic Labs signer). An optional sha256 pins the exact build. There is no environment variable, path option or PATH search, so release builds never trust a caller-supplied engine path. Exit 1 with a typed `ENGINE_*` code on any failure |
92
92
  | Candidate signing | `sign-candidate --platform win\|mac --artifact <dir> --candidate-receipt <receipt.json> --out <binding.json> [--manifest <sign.json>] [--publisher <subject>] [--dry-run]`: signs the exact unsigned candidate bytes in place instead of rebuilding from source. The candidate receipt (`{ schema: 1, files: [{ path, sha256 }] }`) is checked first and any mismatch refuses; Windows signs through Azure Artifact Signing (`--publisher` pins the MSIX signer subject), macOS through Developer ID with fixed identifiers from `--manifest`. Writes a binding receipt (`rebuilt: false`, candidate sha -> signed sha per file). Never builds, notarizes or publishes |
93
93
  | Dev artifact signing | `sign-dev-artifact --artifact <dir> --manifest <sign.json> [--metadata <payload.json>] [--evidence <path>] [--dry-run]`: Developer ID signs an existing CI dev artifact per file with fixed identifiers (macOS). It verifies the recorded hashes in the payload metadata (default `payload.json`, `{ schema: 1, files: [{ path, sha256 }] }`) first, signs each sign-manifest entry (`file` relative to the artifact, `identifier` required, nested code before its bundle) through the dev-signing identity (missing Developer ID: `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`, no ad hoc fallback), verifies with `codesign --verify --strict`, reads back Identifier and TeamIdentifier, refreshes the metadata hashes, and writes evidence JSON. Never builds, notarizes or publishes. The payload metadata must sit outside every signed entry (`SIGN_DEV_ARTIFACT_METADATA_IN_SIGNED_ENTRY`). An optional declarative `refresh` in the sign manifest recomputes digests that other JSON files record about signed bytes: `{ "refreshRoot": "<dir inside the artifact>", "refresh": [{ "file", "pointer" (JSON pointer to an existing string), "compute": "sha256-file" \| "sha256-tree-v1" \| "verify-sha256-file", "of", "format"?, "currentMatches"? }] }` (`file` and `of` relative to `refreshRoot`; `sha256-tree-v1` = sha256 over the sorted relative paths, each as path, NUL, 8-byte big-endian length, bytes; `verify-sha256-file` writes nothing and refuses a digest that changed; `format` embeds the digest in a locator string through one `{value}`; `currentMatches` refuses to overwrite a value of another kind, such as a signed attestation locator). Order is enforced: operations run after the entries they read are signed and before the first entry that contains a refreshed file, so that bundle (the wrapper) signs last over the final bytes; an operation may not read what a later one writes. JSON is rewritten in place with key order kept, and a file the writer would have to reformat is refused. Evidence gains a `refresh` list |
@@ -305,25 +305,14 @@ runs a runner once with the arguments after `--` (watch mode, one project); `--
305
305
  one suite is selected. `--list [--json]` prints the resolved commands and runs nothing. Wrap it as the repository needs, for
306
306
  example `rustright storage run --app <app> -- right-release test`.
307
307
 
308
- #### The `sign` step: one signing identity per product, every build (rightkit#1303)
308
+ #### One signing identity per product, every build (rightkit#1303; the suite `sign` step was removed, GATE-CONTRACT GC-A07)
309
309
 
310
- The route probe (`--to <stage> --route`) judges JSON configs only; a legacy `right-release.config.mjs` is enforced at run time instead (the suite runner before any suite, the dev runner before a target builds). A successful sign step exports `RIGHTKIT_QA_APP_BINARY` and `RIGHTKIT_QA_SIGNING_EVIDENCE` (path of `signing-evidence.json`, kept until the stage ends). On Windows a missing silent Azure token refuses first with `SIGN_WINDOWS_TOKEN_UNAVAILABLE`.
310
+ The route probe (`--to <stage> --route`) judges JSON configs only; a legacy `right-release.config.mjs` is enforced at run time instead (the suite runner before any suite, the dev runner before a target builds). On Windows a missing silent Azure token refuses first with `SIGN_WINDOWS_TOKEN_UNAVAILABLE`.
311
311
 
312
- A suite that builds a product (a `tauri build` or `tauri bundle`, or a `cargo build` of the Tauri package or a sidecar package) must be followed by a `sign` step that names it, so a QA run exercises the code identity the release carries (macOS keys privacy grants to the designated requirement):
313
-
314
- ```js
315
- suites: [
316
- { name: "build-app", cmd: "pnpm", args: ["exec", "tauri", "build", "--target", "aarch64-apple-darwin", "--no-bundle", "--", "--profile", "dev", "--locked"] },
317
- // `from` names an EARLIER suite; `path` is relative to the output directory of that suite's carried receipt. The main binary is the file `mainBinaryName` names, else the first.
318
- { name: "sign-app", sign: { files: [{ from: "build-app", path: "aarch64-apple-darwin/debug/heardright" }] } },
319
- { name: "native-journeys", cmd: "rightkit-qa", args: ["run-tests", "--app", "heardright"] }, // runs with RIGHTKIT_QA_APP_BINARY = the signed copy
320
- ],
321
- ```
312
+ A `sign` entry in `tests.suites` is no longer a step (GATE-CONTRACT GC-A07: no build stage signs, stages or runs a debug, test-mode or QA copy); `right-release test` refuses it as an unknown key. A suite that builds a product (a `tauri build` or `tauri bundle`, or a `cargo build` of the Tauri package or a sidecar package) is still judged by the route policy below.
322
313
 
323
314
  - **The identity is derived, never declared.** The product's `tauri.conf.json` `identifier` is the one id (a `tauri.<platform>.conf.json` that changes it is refused; the Mac `development.targets.mac.install.bundleId`, when set, must equal it; a dev or test Tauri `--config` that sets `identifier` is refused). A `sign` step takes no identity, identifier, team or thumbprint key. The Mac signs with the Developer ID Application identity of team `6KLGD3LLKF` through `resolveMacosDevelopmentSigning`, `signMacosEntries` and the signing agent (`runMacCodesignCommands`): there is no fallback to Apple Development or ad hoc signing. The main binary (the file named by `tauri.conf.json` `mainBinaryName` when it declares one, else the first file) is signed with `--identifier <bundle id>`; every other file is a sidecar signed with the identifier `resolveBundleIdentifier` gives it from `sign.identifiers` or `sign.identifierTemplate` (keyed `Contents/MacOS/<name>`; a sidecar the release signs with none is signed with no `--identifier`, as the release does, and must read back as the file name codesign derives). Windows signs through `sign-windows.mjs` (Azure Artifact Signing) and the identity is the signer subject `CN=Damned Ventures LLC`, never a thumbprint.
324
- - **It runs in-process, in the gate's own session, outside the broker** (like QA: the signing agent, the Keychain and the Azure signer are the session's), and a `sign` step is never a carried step: `build-launch.mjs` refuses it on every route. It verifies the file is in the output directory of the named suite's verified receipts (a path that leaves it, by `..` or a link, is refused), copies it with its sha256 checked into a gate-managed directory outside the snapshot, the checkout and every broker output directory, signs the copy and never the receipt's file, and reads the signature back: Mac `Identifier`, `TeamIdentifier` and the designated requirement, which must equal the release-shaped `expectedDesignatedRequirement(bundleId, team)` (`assertStableDesignatedRequirement`; an unreadable, ad hoc or cdhash-bound requirement is refused too); Windows Authenticode with the signer subject.
325
- - **Evidence, then the variable.** Only after every file is signed and read back does the step write its `signing` evidence (file, sha256 before and after, identifier, team or subject, requirement) to the carrier's ledger and an evidence file, print a `<prefix>_suite_signed` event, and set `RIGHTKIT_QA_APP_BINARY` to the main binary's signed copy for the suites after it (a step that signs only sidecars sets none). A failed step sets nothing and stops the run; its copies are removed when the run ends.
326
- - **The gate refuses before it builds, on the Mac and the Dell** (`routePlan`, so `rightkit-gate --to test --route` answers the same; Linux and hosted CI are exempt): a suite that builds a product with no later `sign` step naming it (`RK_SUITE_UNSIGNED_BUILD`), `sign-qa-bundle` or `RIGHTKIT_DEV_SIGNING` ad hoc signing (`RK_SUITE_ADHOC_SIGNING`), an identifier override (`RK_SUITE_IDENTIFIER_OVERRIDE`), a Mac `install.bundleId` that is not the Tauri identifier (`RK_SUITE_IDENTIFIER_MISMATCH`) and a Windows development `install.kind: "tauri"` (`RK_DEV_WIN_UNSIGNED_INSTALL`). A suite that launches a product bin (a `cargo run`, or an integration test that reads `CARGO_BIN_EXE_<bin>`) needs an EARLIER sign step that names that executable: a `sign.files` entry whose `from` is the suite that builds the bin and whose `path` is `<triple>/<profile>/<bin>[.exe]`; a sign step that covers only another binary (the shell, while the test launches the engine) is refused with `RK_SUITE_LAUNCH_UNBOUND`.
315
+ - **The gate refuses before it builds, on the Mac and the Dell** (`routePlan`, so `rightkit-gate --to test --route` answers the same; Linux and hosted CI are exempt): a suite that builds a product that builds a product (`RK_SUITE_UNSIGNED_BUILD`), `RIGHTKIT_DEV_SIGNING` ad hoc signing (`RK_SUITE_ADHOC_SIGNING`), an identifier override (`RK_SUITE_IDENTIFIER_OVERRIDE`), a Mac `install.bundleId` that is not the Tauri identifier (`RK_SUITE_IDENTIFIER_MISMATCH`) and a Windows development `install.kind: "tauri"` (`RK_DEV_WIN_UNSIGNED_INSTALL`). A suite that launches a product bin (a `cargo run`, or an integration test that reads `CARGO_BIN_EXE_<bin>`) needs an EARLIER sign step that names that executable: a `sign.files` entry whose `from` is the suite that builds the bin and whose `path` is `<triple>/<profile>/<bin>[.exe]`; a sign step that covers only another binary (the shell, while the test launches the engine) is refused with `RK_SUITE_LAUNCH_UNBOUND`.
327
316
 
328
317
  `nativeHelpers` entries declare `package` (the app's own SwiftPM package directory, committed in the app) and `product`. `sharedPackage` and `RIGHTKIT_SWIFT_ROOT` are retired (rightkit#1302): a config that uses `sharedPackage` is refused (`NATIVE_HELPERS_INVALID`) with the replacement, `package: "<app SwiftPM package>"` and `product: "rightkit-cloudkit-helper"`, the shared helper pinned by URL and tag through that package's own `Package.swift`.
329
318
 
@@ -422,21 +411,21 @@ app in `notarize.app`. Declare the file in `updater.artifacts` as before. See `d
422
411
 
423
412
  ## QA receipts and admission (warn by default, refuse per app)
424
413
 
425
- `right-release qa --stage source|installed --platform mac|win` runs `rightkit-qa` and writes a MAC'd receipt. Admission is a per-app policy in `qa-admission-policy.json`:
414
+ `right-release qa --stage installed --platform mac|win --release <sealed-id>` runs `rightkit-qa` against the installed copy and writes a MAC'd receipt. There is no source stage (GATE-CONTRACT GC-A07): `--stage source` refuses, and the package stage runs no QA. Admission is a per-app policy in `qa-admission-policy.json`:
426
415
 
427
416
  ```json
428
417
  { "schema": 1,
429
418
  "apps": {
430
419
  "postright": { "mode": "refuse", "since": "2026-11-02", "set_by": "adrian" },
431
420
  "genright": { "mode": "warn", "since": "2026-10-24", "set_by": "adrian",
432
- "stages": { "source": { "mode": "refuse", "since": "2026-11-09", "set_by": "adrian" } } } },
421
+ "stages": { "installed": { "mode": "refuse", "since": "2026-11-09", "set_by": "adrian" } } } },
433
422
  "approvedRunners": [ { "sha256": "<64 hex>", "version": "0.9.0", "platform": "mac", "set_by": "adrian", "since": "2026-10-24" } ] }
434
423
  ```
435
424
 
436
- **Per-stage modes.** An app entry is one mode for both stages, or carries `stages: { source?, installed? }` entries that override it for that stage (`source` is the build; `installed` is upload, publish-update and github). The `installed` stage cannot pass until the installed lane exists, so move an app to `refuse` for `source` first: `qa policy set --app <app> --stage source --mode refuse --set-by <name>`. Without `--stage` one mode governs both stages and any `stages` object is dropped. A stage that was `refuse` and is no longer (removed, or `warn`) needs `set_by: "adrian"`, checked per stage on write and on load.
425
+ **Per-stage modes.** An app entry is one mode, or carries `stages: { installed? }` that overrides it for the publication admission (`installed` is upload, publish-update and github; it is the only stage, GATE-CONTRACT GC-A07). `qa policy set --app <app> --stage installed --mode refuse --set-by <name>` writes the stage entry; without `--stage` the app-level mode is written and any `stages` object is dropped.
437
426
 
438
427
  - **`warn` (default; an app with no entry).** A missing or invalid receipt does not block. `build`, `upload`, `publish-update` and `github` print `[right-release] QA warning: ...` to stderr, continue, and record the outcome (below).
439
- - **`refuse`.** The same refusal throws `QA admission refused for <app> (policy refuse since ... by ...)`; nothing is sealed, uploaded or published. Switch an app on with `right-release qa policy set --app <app> --mode refuse --set-by <name> [--stage source|installed]` (or `node release-qa-policy.mjs set ...`). Moving an app back to `warn` (or deleting its entry) is refused unless `set_by` is `adrian`; a hand edit that does so is caught on every load by comparing with the copy committed at `RIGHT_RELEASE_QA_POLICY_BASELINE_REF` (default `HEAD`; CI should use `origin/main`). `set_by` is a recorded claim, not an authenticated identity: the file must be owner-protected in the repository (CODEOWNERS and branch protection), and a downgrade that was already committed becomes the new baseline. An unreadable or malformed policy file is a hard error, never a fall back to warn. When a baseline ref is requested (`RIGHT_RELEASE_QA_POLICY_BASELINE_REF` or the `baselineRef` argument) and git cannot show the policy at that ref, the load fails closed instead of running without a baseline; with nothing requested a checkout without that history (an app's `node_modules`) still loads with no baseline.
428
+ - **`refuse`.** The same refusal throws `QA admission refused for <app> (policy refuse since ... by ...)`; nothing is sealed, uploaded or published. Switch an app on with `right-release qa policy set --app <app> --mode refuse --set-by <name> [--stage installed]` (or `node release-qa-policy.mjs set ...`). Moving an app back to `warn` (or deleting its entry) is refused unless `set_by` is `adrian`; a hand edit that does so is caught on every load by comparing with the copy committed at `RIGHT_RELEASE_QA_POLICY_BASELINE_REF` (default `HEAD`; CI should use `origin/main`). `set_by` is a recorded claim, not an authenticated identity: the file must be owner-protected in the repository (CODEOWNERS and branch protection), and a downgrade that was already committed becomes the new baseline. An unreadable or malformed policy file is a hard error, never a fall back to warn. When a baseline ref is requested (`RIGHT_RELEASE_QA_POLICY_BASELINE_REF` or the `baselineRef` argument) and git cannot show the policy at that ref, the load fails closed instead of running without a baseline; with nothing requested a checkout without that history (an app's `node_modules`) still loads with no baseline.
440
429
 
441
430
  **Approving a runner.** `right-release qa policy approve-runner --runner <file> --version <v> --platform mac|win --set-by adrian [--note <text>]` digests the file, runs `<file> --version` (the output must be exactly `rightkit-qa <v>`), requires `<v>` to be the published `rightkit-qa` in `rightkit-versions.json` (`registry.cargo`), re-digests the file to catch a swap while it ran, and appends `{ sha256, version, platform, set_by, since, note? }`. Only `adrian` may approve (a runner entry is new admission authority; a hand-added entry by anyone else is refused on load against the baseline). An approval is per platform because a runner's digest differs per host and OS; an entry with no `platform` (older files) approves the digest on either. Running the approved binary is the caller's job: set `qa.runner` in the release config to the managed path, since a bare `which rightkit-qa` can resolve to a different build.
442
431
 
@@ -444,16 +433,14 @@ Hardening, applied in both modes (a failure is a warning in `warn` and a refusal
444
433
 
445
434
  1. **Platform and release id.** The sealed receipt must be for the platform and the release id being sealed or published.
446
435
  2. **Approved runner.** The `rightkit-qa` executable's sha256 recorded in the receipt must be listed in `approvedRunners` for the platform being sealed or published (an entry without `platform` approves either). An empty list approves nothing. The binary is digested before the run and again after it; a swap during the run is refused.
447
- 3. **Every release-critical journey.** With `qa.journeys` in the release config (`{ manifest?: "journeys.toml", receipt?: "<path>", args?: { source?, installed? } }`) `right-release qa` runs `rightkit-qa journeys run` (or reads `receipt`), validates the `rightkit.journeys.receipt/1` through `rightkit-qa journeys validate-receipt --manifest .. --tree ..` (exit code 0 and a final `ACCEPTED` line), and seals a `journeys` summary. `release-qa-floor.json` maps the `journey` floor id to "all release-critical journeys passed": at admission the required set is recomputed from the `journeys.toml` committed at the receipt's commit (every `release_critical` journey that lists the platform), and each must be `passed`. Zero critical journeys is never a pass. One matching check no longer satisfies the floor.
436
+ 3. **Every release-critical journey.** With `qa.journeys` in the release config (`{ manifest?: "journeys.toml", receipt?: "<path>", args?: { installed? } }`) `right-release qa` runs `rightkit-qa journeys run` (or reads `receipt`), validates the `rightkit.journeys.receipt/1` through `rightkit-qa journeys validate-receipt --manifest .. --tree ..` (exit code 0 and a final `ACCEPTED` line), and seals a `journeys` summary. `release-qa-floor.json` maps the `journey` floor id to "all release-critical journeys passed": at admission the required set is recomputed from the `journeys.toml` committed at the receipt's commit (every `release_critical` journey that lists the platform), and each must be `passed`. Zero critical journeys is never a pass. One matching check no longer satisfies the floor.
448
437
  4. **Signing outside test execution.** Checked: the receipt records the test processes' pids and the window they ran in; admission refuses a receipt sealed before that window ended, signing that began before it ended (`signingStartedAt`), a sealing or admitting process that is a test process, `RIGHT_RELEASE_QA_STAGE` set at admission (admission or signing inside a QA run), and `right-release qa` itself refuses to seal inside a QA run. Not checkable here: that test processes cannot reach the signing key (OS credential store and signing agent ACL) and that CI runs QA and signing as separate jobs (the carried receipt, rightkit#183, is the hook).
449
438
 
450
439
  `right-release qa` keeps the older behavior for evidence checks and verification of the receipt itself; the outcome is recorded:
451
440
 
452
- - Source stage: sealed into the manifest as `qa: { status: "passed", sourceReceiptDigest, floorDigest }` or `qa: { status: "missing" | "rejected", reason }`.
453
441
  - Installed stage: written to `.right-release/state/<releaseId>/<platformDir>/qa-installed.json` as `{ status, reason?, receiptDigest?, checkedAt, provider }`. It is written when publication starts, so a later upload failure still leaves a record.
454
442
 
455
- Audit ad hoc or on a weekly schedule with `right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]`. It reads the app repo's sealed manifests and state and lists source and installed QA status per release and platform (passed, missing, rejected, not-uploaded). It exits 0 when every uploaded build has passing QA and 1 when any uploaded build lacks it. An installed receipt can only chain to a build sealed with a passing source receipt, so a build sealed with `qa.status: "missing"` needs source QA and a rebuild before installed QA can pass.
456
-
443
+ Audit ad hoc or on a weekly schedule with `right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]`. It reads the app repo's sealed manifests and state and lists installed QA status per release and platform (passed, missing, rejected, not-uploaded). It exits 0 when every uploaded build has passing QA and 1 when any uploaded build lacks it.
457
444
  ## Gate consumer rows
458
445
 
459
446
  `ci/gate.config.json` `rust.<crate>.consumerRows` adds gate rows that check the crate through a separate Cargo workspace: `{ "manifestPath": "ci/consumers/<name>/Cargo.toml", "args": [...], "kind": "check", "targets": ["<triple>"], "locked": true }` runs `cargo check --manifest-path <manifestPath> --locked --target <triple> <args>` once per target, after the crate's own checks, and records each as an evidence step (so `--from-evidence` requires it). Only `manifestPath` is required; unknown fields are refused. Clippy with `-D warnings` runs on every `featureSets` entry, not only the first.
@@ -532,17 +519,6 @@ enforcement. D4 is ratified; pending activation does not mean pending ratificati
532
519
  - **Adoption record** (`<app>/rightkit-adoption.json`, schema 1): `app`, `manifestSha256` (the manifest file's hash; a stale record is `adoption-stale`), `target`, optional `locks`, `cargoTree { file, sha256 }` and `tasks.<AP-id>.adopted[] { capability, packages, entrypoints, receipt { file, sha256 } }`. A task must be `ready` in the manifest and every capability it requires must be adopted. Each adopted package must be pinned by the lockfile at exactly the manifest version and integrity from the crates.io registry (`Cargo.lock`), the npm registry (`pnpm-lock.yaml` v9) or the tag commit (`Package.resolved`), with no other version of the name in the lock. For cargo, the saved `cargo tree -e features` must show that exact version, every declared feature active and published, and the declared feature set equal to a passing qualification row for the target's host. The active target must be published and qualified.
533
520
  - **Receipt** (JSON only, schema 1): `kind` `test` or `run`, `app`, `capability`, one of the adopted `entrypoint`s (a real file inside the app, optional `#symbol` that must appear in it), `packages` naming the pinned `<id>@<version>`, `target`, a `command`, `exitCode: 0`, `finishedAt`, and `lockfileSha256.<cargo|pnpm|swift>` equal to the current lockfile (a receipt from before the last lock change is refused).
534
521
 
535
- ## QA-only ad hoc bundle signing (`sign-qa-bundle`)
536
-
537
- On the Mac and the Dell the gate refuses a gate-run suite or dev build whose config calls this command (`RK_SUITE_ADHOC_SIGNING`, rightkit#1303): a gate-run build is signed with the product's one identity (see the `sign` step in the declared test suites section).
538
-
539
- `right-release sign-qa-bundle --app <abs .app> --qa-root <abs dir> [--entitlements <abs plist>] [--out <evidence.json>]` ad hoc signs an assembled QA bundle (for example an Accessibility QA copy) that lives inside a QA scratch root. It exists because `codesign --force --deep --sign -` sent through the signing agent (SSH or non-Aqua session) is refused by design; those refusals are unchanged and this command never calls the agent.
540
-
541
- - macOS only. Refused before any command runs: a relative path, `/Applications` or `~/Applications` (lexical or resolved), a bundle whose real path is not inside the real QA root (links are resolved first), a QA root that is `/`, the home directory or an install location, and a target that is not a `.app` directory.
542
- - Nested code is signed inside-out, deepest first (`Contents/Frameworks/*` frameworks and dylibs, other `*.dylib`, `*.xpc`, nested `*.app`, `Contents/Library/LoginItems`, extra `Contents/MacOS` executables), then the outer bundle, each as `/usr/bin/codesign --force --sign - [--entitlements <reduced>] <item>`. Never `--deep`, never a real identity. `codesign --verify --strict` runs on the bundle last.
543
- - `--entitlements` is reduced like ad hoc development signing (only `com.apple.security.cs.*` and `com.apple.security.device.audio-input` survive) and applies to the outer bundle only.
544
- - The receipt (returned, and written with `--out`) records `signature: "adhoc"`, `releaseArtifact: false`, `qaOnly: true` and the items signed in order. The result is never notarized, sealed, published or installed; use the Developer ID paths for anything that ships.
545
-
546
522
  ## Entry points
547
523
 
548
524
  Bins: `right-release` (`cli/right-release.mjs`), `rightkit-gate` (`bin/rightkit-gate.mjs`), `rightkit-linux-setup` (`bin/rightkit-linux-setup.mjs`) and `rightkit-agent-hook` (`bin/rightkit-agent-hook.mjs`). The CLI is the supported surface.
package/android-dev.mjs CHANGED
@@ -60,8 +60,9 @@ export function validateAndroidDevelopment(target, { root }) {
60
60
  return { ndk };
61
61
  }
62
62
 
63
+ // GATE-CONTRACT GC-A07: the dev copy's Rust is the release profile (one unit set and cache with the release build).
63
64
  export function androidDevelopmentNdkCommand(ndk) {
64
- return androidNdkCommand(ndk, { profile: "dev" });
65
+ return androidNdkCommand(ndk, { profile: "release" });
65
66
  }
66
67
 
67
68
  export function resolveAdb(env = process.env, { exists = existsSync, platform = process.platform } = {}) {
package/app-check.mjs CHANGED
@@ -1494,7 +1494,7 @@ export const BUILD_REFUSES = Object.freeze(["switches-inventory"]);
1494
1494
 
1495
1495
  /** The checked commands that qualify, sign, ship or upload: they owe the release tier (every rule). The rest owe the compile tier. */
1496
1496
  export const RELEASE_TIER_COMMANDS = Object.freeze(new Set(["release", "upload", "publish", "publish-update", "github", "sign-windows", "sign-updater", "create-mac-updater", "sign-candidate"]));
1497
- /** `qa --stage installed` qualifies the sealed build and owes the release tier; `qa --stage source` runs inside the package stage and owes the compile tier. */
1497
+ /** `qa --stage installed` qualifies the sealed build and owes the release tier; any other `qa` stage owes the compile tier (`qa --stage source` is refused, GATE-CONTRACT GC-A07). */
1498
1498
  export const tierForCommand = (name, args = []) => (name === "qa" ? (args.includes("installed") ? "release" : "compile") : RELEASE_TIER_COMMANDS.has(name) ? "release" : "compile");
1499
1499
 
1500
1500
  /** Every other right-release command, each with why the check does not guard it. A command that is in neither map fails the
@@ -1506,7 +1506,6 @@ export const UNCHECKED_COMMANDS = new Map([
1506
1506
  ["hardening", "scans artifacts named on the command line; reads no source tree"],
1507
1507
  ["harden", "alias of hardening"],
1508
1508
  ["hardeningscan", "alias of hardening"],
1509
- ["sign-qa-bundle", "QA-only ad hoc signing inside a scratch root; not a release artifact"],
1510
1509
  ["engine-locate", "prints the verified path of an installed engine; reads no app source"],
1511
1510
  ["init", "renames the desktop template in place; there is no committed app to check yet"],
1512
1511
  ["pe", "inspects one PE file named on the command line"],
package/app-closure.mjs CHANGED
@@ -159,7 +159,7 @@ export function closureOf({ scopeDirs, npmUnits = [], rustUnits = [], readFile }
159
159
  // The Class A Cargo scans deterministic-scans.mjs plans: `cargo-metadata|cargo-deny|cargo-audit[:<workspace label>]` run in the Cargo workspace
160
160
  // named by the scan's `cwd` (none = the repository root workspace); `cargo-package-list:<unit name>` runs for one unit. The workspace label
161
161
  // in an id is not always the directory (a crate-per-workspace layout labels by crate), so the workspace is read from `cwd`, never the id.
162
- const CARGO_WORKSPACE_SCAN = /^cargo-(?:metadata|deny|audit)(?::.+)?$/;
162
+ const CARGO_WORKSPACE_SCAN = /^(?:cargo-(?:metadata|deny|audit)|build-helpers)(?::.+)?$/;
163
163
  const CARGO_PACKAGE_LIST = /^cargo-package-list:(.+)$/;
164
164
  const cleanWorkspace = (dir) => posix(dir ?? ".").replace(/^\.\//, "").replace(/\/+$/, "") || ".";
165
165
 
@@ -180,6 +180,7 @@ export function scopeScans(scans, closure, { rustUnits = [], npmUnits = [] } = {
180
180
  const kept = []; const dropped = [];
181
181
  for (const s of scans) {
182
182
  const npm = /^(?:tsc|eslint|biome|test:npm):(.+)$/.exec(s.id);
183
+ // A cross-target scan id ends in `@<target triple>` (clippy:<crate>@aarch64-apple-darwin, cross-targets.mjs); a crate name never contains `@`.
183
184
  const rust = /^(?:clippy|check|check-features|check-dependent|doc|test:rust|test:rust-serial):(.+)$/.exec(s.id);
184
185
  const cargoList = CARGO_PACKAGE_LIST.exec(s.id);
185
186
  let include = true;
@@ -199,7 +200,7 @@ export function scopeScans(scans, closure, { rustUnits = [], npmUnits = [] } = {
199
200
  // that could compile unrelated units. Scoped B execution is refused by the gate before planning.
200
201
  include = selected.length === s.batch.units.length;
201
202
  }
202
- else if (rust) { const name = names.find((n) => rust[1] === n || rust[1].startsWith(`${n}:`)); if (!name) throw problem(`scan ${s.id} has no known Rust owner`); include = closure.rust.has(name); }
203
+ else if (rust) { const owner = rust[1].replace(/@[^@:]+$/, ""); const name = names.find((n) => owner === n || owner.startsWith(`${n}:`)); if (!name) throw problem(`scan ${s.id} has no known Rust owner`); include = closure.rust.has(name); }
203
204
  else if (s.class === "B" || ["suite-api", "generated-docs", "test:scripts-ci"].includes(s.id)) include = closure.dirs.includes(".");
204
205
  else if (s.executes === true || s.kind === "test") {
205
206
  if (closure.dirs.includes(".")) include = true;
package/assembled-app.mjs CHANGED
@@ -138,7 +138,7 @@ export function validateMacAssembly(spec) {
138
138
  return {
139
139
  platform: "mac", productName: spec.productName, bundleId: spec.bundleId, targetTriple: spec.targetTriple,
140
140
  stageReceipt: containedRelative(spec.stageReceipt ?? DEFAULT_STAGE_RECEIPT, `${where}.stageReceipt`),
141
- outer: { tool: "xcodebuild", ...(outer.projectSpec ? { projectSpec: containedRelative(outer.projectSpec, `${where}.outer.projectSpec`) } : {}), project, scheme: outer.scheme, configuration: outer.configuration ?? "Release", product, unsetEnv: stringList(outer.unsetEnv, `${where}.outer.unsetEnv`), extraBuildSettings: { ...settings } },
141
+ outer: { tool: "xcodebuild", ...(outer.projectSpec ? { projectSpec: containedRelative(outer.projectSpec, `${where}.outer.projectSpec`) } : {}), project, scheme: outer.scheme, configuration: releaseConfiguration(outer.configuration, where), product, unsetEnv: stringList(outer.unsetEnv, `${where}.outer.unsetEnv`), extraBuildSettings: { ...settings } },
142
142
  nested, helpers, plugins, launchDaemons: plist(spec.launchDaemons, "launchDaemons"), launchAgents: plist(spec.launchAgents, "launchAgents"), resources,
143
143
  bundleVersion: spec.bundleVersion, require: relativeList(spec.require, `${where}.require`),
144
144
  ...(spec.devMarker === undefined ? {} : { devMarker: containedRelative(spec.devMarker, `${where}.devMarker`) }),
@@ -216,7 +216,7 @@ export function loadStageReceipt({ root, spec, profile, fs = nodeFs }) {
216
216
  find(name, kind = "cargo-bin") {
217
217
  const row = receipt.files.find((candidate) => candidate.kind === kind && candidate.name === name);
218
218
  if (!row) refuse("ASSEMBLY_RECEIPT_ROW_MISSING", `stage receipt has no ${kind} row ${name}`);
219
- if (kind === "cargo-bin" && row.profile !== profile) refuse("ASSEMBLY_RECEIPT_PROFILE", `${name} was staged with Cargo profile ${row.profile}; this ${profile} assembly needs ${profile}`);
219
+ if (kind === "cargo-bin" && row.profile !== "release") refuse("ASSEMBLY_RECEIPT_PROFILE", `${name} was staged with Cargo profile ${row.profile}; every assembly, dev or release, uses the release profile (GATE-CONTRACT GC-A15)`);
220
220
  const file = path.resolve(root, row.path);
221
221
  let actual;
222
222
  try { actual = sha256Of(fs, file); } catch (error) { refuse("ASSEMBLY_RECEIPT_FILE_MISSING", `staged ${name} is missing: ${file} (${error.code ?? error.message})`); }
@@ -256,7 +256,15 @@ export function bindingDocument({ profile, targetTriple, bound }) {
256
256
  return { schema: 1, profile, devOnly: profile !== "release", target: targetTriple, files: Object.fromEntries(bound.map(({ dest, sha256 }) => [dest, sha256])) };
257
257
  }
258
258
 
259
- function profileDirectory(profile) { return profile === "release" ? "release" : "debug"; }
259
+ // Cargo builds only the release profile (GATE-CONTRACT GC-A15): a dev assembly differs from a release one in its QA marker and signing,
260
+ // never in the Cargo output folder, so both read target/<triple>/release.
261
+ function profileDirectory() { return "release"; }
262
+
263
+ // The outer Xcode app builds only the Release configuration (GC-A15: no debug build anywhere); any other configuration is refused.
264
+ function releaseConfiguration(configuration, where) {
265
+ if (configuration !== undefined && configuration !== "Release") invalid(`${where}.outer.configuration must be "Release" (no debug build anywhere, GATE-CONTRACT GC-A15); found ${JSON.stringify(configuration)}`);
266
+ return "Release";
267
+ }
260
268
 
261
269
  /** The built Tauri .app of one `nested` entry (Cargo's own target directory; the product name is the bundle name). */
262
270
  export function resolveNestedApp({ root, entry, spec, profile, deps = {} }) {
Binary file
@@ -0,0 +1,6 @@
1
+ {
2
+ "schema": 1,
3
+ "sha256": "a41ce13fd3bac31db0230d70bed20db0ed9bf554bdae2255bdf2064589b48f29",
4
+ "identity": "Developer ID Application: Adrian D'souza (6KLGD3LLKF)",
5
+ "codesign": "strict-valid"
6
+ }
@@ -76,6 +76,7 @@ import { envNamed, gateRunsRoot, resolveManagedRoots } from "../managed-roots.mj
76
76
  import { resolveBrokerClient, spawnBrokerClient } from "../broker-client.mjs";
77
77
  import os from "node:os";
78
78
  import path from "node:path";
79
+ import { hostTarget } from "../cross-targets.mjs";
79
80
  import { FOREIGN_PRE_COMMIT, retireHooksIn } from "../hook-retirement.mjs";
80
81
  import { fileURLToPath } from "node:url";
81
82
  import { BIOME_CONFIG_FILES, ESLINT_CONFIG_FILES, LOCAL_CLASS_B_JOBS, WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, defaultSpawnAsync, digestScanSet, fileScanCache, findRustfmt, metadataArgs, planScans, runScans, scanToolPolicyFromPipeline, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
@@ -86,7 +87,7 @@ const gateResolveExecutor = (args) => resolveExecutor({ ...args, gateWrappers: t
86
87
  import { ALL_UNITS_ERRORS, ALL_UNITS_EXCEPTIONS_FILE, BROKER_EXCEPTIONS_FILE, BROKER_EXCEPTION_ERRORS, CARGO_WORKSPACE_LIST_FILE, CARGO_WORKSPACE_LIST_KEY, RIGHTKIT_IDENTITY_FILE, authorizeAllUnits, authorizeNoBroker, checkCargoWorkspaceList, describeAllUnitsException, describeBrokerException, isRightKitIdentity, unitsOfSelector } from "../broker-exception.mjs";
87
88
  import { SIGNING_REFUSAL_CODES, suiteSigningRefusals } from "../suite-signing-policy.mjs";
88
89
  import { gitListRustFiles } from "../signing-files.mjs";
89
- import { LINUX_BROKER_ROUTE, NO_BROKER_ENV, directPlatform, linuxBrokerDeclared, managedRuntimeClient, realCargoOf } from "../managed-cargo.mjs";
90
+ import { LINUX_BROKER_ROUTE, NO_BROKER_ENV, directCargoPath, directPlatform, linuxBrokerDeclared, managedRuntimeClient, realCargoOf } from "../managed-cargo.mjs";
90
91
  import { createBrokerLive } from "../broker-admission.mjs";
91
92
  import { bindingOf, compileOutputsRecord, compileReuseProblem, endRun, readReceipt, RECEIPT_SCHEMA, receiptKey, sameBinding, sameSource, sealedFor, stagesTo, startRun, STAGES, STAGE_SOURCES, writeReceipt } from "../gate-stages.mjs";
92
93
  import { spawnSync as spawnInherit } from "../background-process.mjs";
@@ -765,7 +766,8 @@ const ANCESTOR_RUST_INPUTS = [".cargo", "rust-toolchain", "rust-toolchain.toml",
765
766
  * `known` the name is the first segment, as for a repository with one root workspace.
766
767
  */
767
768
  export function rustUnitOfScanId(id, known = null) {
768
- const parts = String(id).split(":");
769
+ // A cross-target scan id ends in `@<target triple>` (cross-targets.mjs); a crate name never contains `@`.
770
+ const parts = String(id).replace(/@[^@:]+$/, "").split(":");
769
771
  let rest;
770
772
  if (["clippy", "check", "check-features", "check-dependent", "doc"].includes(parts[0])) rest = parts.slice(1);
771
773
  else if (parts[0] === "test" && (parts[1] === "rust" || parts[1] === "rust-serial")) rest = parts.slice(2);
@@ -1241,7 +1243,7 @@ async function installHook(o, root) {
1241
1243
 
1242
1244
  /**
1243
1245
  * Runs every stage up to `--to`, in order, for HEAD: check (class A), compile (the whole gate over every file), then, through the existing
1244
- * owners, package (source QA, then `right-release build`, which seals the exact artifact set), qualify (installed QA on that sealed set)
1246
+ * owners, package (`right-release build`, which seals the exact artifact set; no source QA, GATE-CONTRACT GC-A07), qualify (installed QA on that sealed set)
1245
1247
  * and release (`right-release upload` of that same sealed set). Stops at the first stage that fails. check and compile are reused from
1246
1248
  * the receipt only when every bound input is unchanged; the right-release stages run under a live door run, so they refuse any other
1247
1249
  * caller (gate-stages.mjs requireDoorRun).
@@ -1649,7 +1651,6 @@ async function runStages(o, args, root, git, io) {
1649
1651
  const packageTarget = await packageSidecarTarget({ o, ds, configs: packageConfigs });
1650
1652
  ok = await stageSidecarsOnce({ o, ds, root, git, configs: packageConfigs, appDir, cli, commonDir, commit, tree, stage, executor, target: packageTarget });
1651
1653
  }
1652
- // Source QA (right-release qa --stage source) exists for mac and win only; a mobile package is the signed build alone.
1653
1654
  // Row 7.04c (R-T1, 7.09): the snapshot holds no Cargo target. On a direct host the checkout's output directory is resolved here, in the primary
1654
1655
  // checkout, and handed to right-release absolute: it binds every manifest's target for this stage, and the sealed manifest must record it back
1655
1656
  // (the door never trusts the child's value alone). One workspace is pinned as a whole; several are refused, naming them. A broker host's target
@@ -1670,16 +1671,7 @@ async function runStages(o, args, root, git, io) {
1670
1671
  else if (packageMulti.note) o.out(`${tag} [package] ${packageMulti.note}`);
1671
1672
  }
1672
1673
  const packageEnv = packagePin.dir ? { CARGO_TARGET_DIR: packagePin.dir } : (packageMulti?.env ?? {});
1673
- // The QA shell the compile built is source QA's binary (rightkit#1309): the compile's recorded output directories are handed to `right-release qa` only
1674
- // while that record still proves the outputs intact; it stages the declared layout from them in a run-scoped directory, never in the snapshot (qa-layout.mjs).
1675
- const compileDirs = receipt.stages.compile?.outputs?.dirs;
1676
- const compiledProblem = (o.compileReuseProblem ?? compileReuseProblem)(receipt.stages.compile, { env: o.env });
1677
- // A direct host's suites (qa.layout.build) have no carried receipts: what they build lands in the output directories pinned here (test-command.mjs).
1678
- const pinnedDirs = packagePin.dirs ?? (packagePin.dir ? [packagePin.dir] : []);
1679
- const buildEnv = pinnedDirs.length ? { ...packageEnv, RIGHTKIT_GATE_TARGET_DIRS: JSON.stringify(pinnedDirs) } : packageEnv;
1680
- const qaEnv = !mobile && !compiledProblem && Array.isArray(compileDirs) && compileDirs.length ? { ...buildEnv, RIGHT_RELEASE_QA_COMPILED_DIRS: JSON.stringify(compileDirs) } : buildEnv;
1681
- if (ok && !mobile && compiledProblem && Array.isArray(compileDirs)) o.out(`${tag} [package] the compile outputs are not handed to source QA: ${compiledProblem}`);
1682
- ok = ok && (mobile || await rightRelease(stage, ["qa", "--stage", "source", "--platform", platform], qaEnv)) && await rightRelease(stage, ["build", "--platform", platform], packageEnv);
1674
+ ok = ok && await rightRelease(stage, ["build", "--platform", platform], packageEnv);
1683
1675
  const sealed = ok ? (o.sealedFor ?? sealedFor)({ root: releaseRoot, commit, platform }) : null;
1684
1676
  if (ok && !sealed) { o.err(`${tag} [package] the build reported success but sealed nothing for ${commit.slice(0, 8)} on ${platform}`); ok = false; }
1685
1677
  if (sealed) extra = { platform, sealed: sealed.id, manifestSha256: sealed.digest };
@@ -2052,6 +2044,41 @@ export function committedWebBuildConfigs({ o, git, commit, appDir = ".", base })
2052
2044
  * Nothing runs when every declared input is already present or the app declares no `web.build`; an input still absent afterwards is left
2053
2045
  * to the compile, which refuses a missing input and names it. Returns true when nothing is owed or the build passed.
2054
2046
  */
2047
+ /**
2048
+ * The web build's cache key (the `dist` cache, GC-T14 result reuse): everything vite can read from the commit, without the Rust crates,
2049
+ * plus the Node version and the vite-visible environment. Committed blobs are listed from the commit itself (`ls-tree -r`); a directory
2050
+ * whose Cargo.toml declares a [package] is a Rust crate and is left out (a workspace-only Cargo.toml, like an app root's, is not), so a
2051
+ * Rust-only commit reuses the bundle. Returns null when the tree cannot be read (no cache then, the build runs).
2052
+ */
2053
+ export function webBuildCacheKey({ git, commit, config, env = process.env, nodeVersion = process.version }) {
2054
+ const listed = git("ls-tree", "-r", "-z", commit);
2055
+ if (failed(listed)) return null;
2056
+ const entries = text(listed).split("\0").filter(Boolean).map((line) => { const tab = line.indexOf("\t"); const oid = line.slice(0, tab).split(" ")[2]; return { path: line.slice(tab + 1), oid }; });
2057
+ const crateDirs = entries.filter((e) => /(^|\/)Cargo\.toml$/.test(e.path) && e.path.includes("/")).filter((e) => {
2058
+ const blob = git("cat-file", "-p", e.oid);
2059
+ return !failed(blob) && /^\s*\[package\]/m.test(text(blob));
2060
+ }).map((e) => `${e.path.slice(0, e.path.lastIndexOf("/"))}/`);
2061
+ const kept = entries.filter((e) => !crateDirs.some((dir) => e.path.startsWith(dir))).map((e) => [e.path, e.oid]);
2062
+ const viteEnv = Object.entries(env ?? {}).filter(([k]) => /^(VITE_|NODE_ENV$)/.test(k)).sort(([a], [b]) => (a < b ? -1 : 1));
2063
+ return createHash("sha256").update(JSON.stringify({ schema: "rightkit.web-build-cache/1", config, node: nodeVersion, env: viteEnv, files: kept })).digest("hex");
2064
+ }
2065
+
2066
+ /** `<managed root>/web-cache/<repo key>`, beside the snapshot (`<managed root>/snapshots/<repo key>`). */
2067
+ export const webBuildCacheRoot = (snapshotPath) => path.join(path.dirname(path.dirname(snapshotPath)), "web-cache", path.basename(snapshotPath));
2068
+
2069
+ /** Copies every declared output from `from` to `to`, keeping modification times (a restored bundle looks unchanged to Cargo). */
2070
+ function copyDeclared(declared, from, to) {
2071
+ for (const rel of declared) {
2072
+ const src = path.join(from, ...rel.split("/"));
2073
+ if (!fs.existsSync(src)) return false;
2074
+ const dest = path.join(to, ...rel.split("/"));
2075
+ fs.rmSync(dest, { recursive: true, force: true });
2076
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
2077
+ fs.cpSync(src, dest, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
2078
+ }
2079
+ return true;
2080
+ }
2081
+
2055
2082
  export async function stageWebBuildOnce({ o, ds, root, git = null, appDir, cli, commonDir, commit, tree, stage, executor = null, tag = "rightkit-gate:" }) {
2056
2083
  if (ds.webBuilt) return true;
2057
2084
  ds.webBuilt = true;
@@ -2069,11 +2096,17 @@ export async function stageWebBuildOnce({ o, ds, root, git = null, appDir, cli,
2069
2096
  for (const config of configs) {
2070
2097
  if (!absent().length) break;
2071
2098
  const before = ds.ledger?.entries ?? [];
2072
- const carrier = await carrierForChildren({ o, ds, root, git, executor, stage, tag, producer: true });
2073
- if (carrier.refusal) { o.err(`${tag} BLOCKED: ${carrier.refusal}; the web build did not run`); return false; }
2074
- const run = startRun({ commonDir, commit, tree, stage, source });
2075
- let r;
2076
- try {
2099
+ // Adrian, 2026-11-02: the web build (vite, any bundling) runs outside the broker, directly in the snapshot; no carrier, no carried call.
2100
+ const carrier = { carried: false, env: {}, finish: async () => true };
2101
+ // The dist cache: same committed web inputs, Node and vite env as an earlier build -> restore its bundle with its original modification
2102
+ // times and skip vite, so the Tauri crate that embeds it stays fresh (Adrian, 2026-11-03: vite output was rebuilt on every gate run).
2103
+ const key = git ? (o.webBuildCacheKey ?? webBuildCacheKey)({ git, commit, config, env: o.env }) : null;
2104
+ const cacheDir = key ? path.join(webBuildCacheRoot(ds.path), key) : null;
2105
+ const restored = Boolean(cacheDir && fs.existsSync(path.join(cacheDir, ".complete")) && copyDeclared(declared, cacheDir, ds.path));
2106
+ if (restored) o.out(`${tag} [${stage}] web-build ${config}: restored from the dist cache (${key.slice(0, 12)}); vite not run`);
2107
+ const run = restored ? null : startRun({ commonDir, commit, tree, stage, source });
2108
+ let r = restored ? { status: 0 } : null;
2109
+ if (!restored) try {
2077
2110
  o.out(`${tag} [${stage}] right-release web-build --config ${path.join(ds.path, config)} (from the snapshot ${ds.path}; declared input(s) ${absent().join(", ")} are absent)`);
2078
2111
  r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, "web-build", "--config", path.join(ds.path, config)], { cwd: snapshotAppRootOf(ds.path, appDir), env: carriedEnv(carrier, { ...cleanEnv, ...run.env }) });
2079
2112
  } finally { endRun(run); }
@@ -2086,6 +2119,21 @@ export async function stageWebBuildOnce({ o, ds, root, git = null, appDir, cli,
2086
2119
  if (problems.length) { for (const p of problems.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [${stage}] web-build ${config}: ${p}`); return false; }
2087
2120
  const priorByPath = new Map(before.map((e) => [e.path, e]));
2088
2121
  ds.ledger = { entries: walked.entries.map((e) => (priorByPath.get(e.path)?.sha256 === e.sha256 ? priorByPath.get(e.path) : { ...e, kind: "generated", producer: { stage: "web-build", step: config } })) };
2122
+ // A fresh, verified bundle is stored for the next run (written beside, then renamed: never a half cache). Older keys are pruned to 3.
2123
+ if (!restored && cacheDir) {
2124
+ try {
2125
+ const staging = `${cacheDir}.tmp-${process.pid}`;
2126
+ fs.rmSync(staging, { recursive: true, force: true });
2127
+ if (copyDeclared(declared, ds.path, staging)) {
2128
+ fs.writeFileSync(path.join(staging, ".complete"), `${new Date().toISOString()}\n`);
2129
+ fs.rmSync(cacheDir, { recursive: true, force: true });
2130
+ fs.renameSync(staging, cacheDir);
2131
+ const root = webBuildCacheRoot(ds.path);
2132
+ const keys = fs.readdirSync(root).filter((n) => /^[0-9a-f]{64}$/.test(n)).map((n) => ({ n, t: fs.statSync(path.join(root, n)).mtimeMs })).sort((a, b) => b.t - a.t);
2133
+ for (const old of keys.slice(3)) fs.rmSync(path.join(root, old.n), { recursive: true, force: true });
2134
+ } else fs.rmSync(staging, { recursive: true, force: true });
2135
+ } catch (e) { o.out(`${tag} [${stage}] web-build ${config}: the dist cache was not written (${e.message}); the next run builds again`); }
2136
+ }
2089
2137
  }
2090
2138
  return true;
2091
2139
  }
@@ -3526,7 +3574,6 @@ export async function main(argv, io = {}) {
3526
3574
  return failed(r) ? [] : text(r).split("\0").filter(Boolean).map(posix);
3527
3575
  };
3528
3576
  if (executor.kind === "broker" && snapshot && (snapshotA || snapshotB) && manifests.length) {
3529
- const { snapshotCargoSpawn } = await import("../snapshot-metadata.mjs");
3530
3577
  const { createSnapshotBroker } = await import("../snapshot-broker.mjs");
3531
3578
  const snapshotCheck = await import("../snapshot-check.mjs");
3532
3579
  const snapshotCommitOf = () => snapshotCheck.commitOf(fs.realpathSync.native(root), String(snapshot), o.spawn);
@@ -3538,8 +3585,15 @@ export async function main(argv, io = {}) {
3538
3585
  discoveryBroker = createSnapshotBroker({ env: o.env, platform: o.platform, checkout: root, commit: { sha: sc.commit, tree: sc.tree }, snapshot: { path: repo, expectedTree: sc.tree }, ownership: o.buildOwnership, client: executor.route === LINUX_BROKER_ROUTE ? executor.client : which("rustright").find((c) => path.isAbsolute(c)), gateFile: callingGatePath(), spawn: o.spawn });
3539
3586
  }
3540
3587
  } catch (error) { o.err(`${tag} BLOCKED: ${error.code ?? "RK_SNAPSHOT_BROKER"}: ${error.message}; nothing compiled`); return 1; }
3541
- snapshotSpawn = snapshotCargoSpawn({ spawn: withDeclaredClient((c, a, opts) => o.spawn(c, a, opts)), cargoCommand: planCargoCommand, snapshotRoot: repo, env: o.env, err: o.err, broker: () => discoveryBroker });
3542
- scanSpawn = snapshotCargoSpawn({ spawn: withDeclaredClient(defaultSpawnAsync), carrySpawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand: planCargoCommand, snapshotRoot: repo, env: o.env, err: o.err, broker: () => discoveryBroker });
3588
+ // GATE-CONTRACT GC-A11 (Adrian, 2026-11-02): Class A never runs in the broker. The snapshot's discovery metadata and every Class A Cargo
3589
+ // read (metadata, fetch, deny, audit, fmt --check) run DIRECTLY with the host's real toolchain cargo (RustRight shims and outer gate
3590
+ // wrappers taken off PATH), each into its own scratch target under the gate's tmp root; nothing is carried.
3591
+ const { directReadSpawn } = await import("../snapshot-metadata.mjs");
3592
+ const pathKey = o.platform === "win32" ? (Object.keys(o.env).find((k) => k.toUpperCase() === "PATH") ?? "Path") : "PATH";
3593
+ const direct = directCargoPath({ env: { ...o.env, PATH: o.env[pathKey] }, exists: o.exists, platform: o.platform, gateWrappers: true });
3594
+ const readOpts = { cargoCommand: planCargoCommand, snapshotRoot: repo, directPath: direct.path, pathKey, tmpRoot: gateTmpRoot({ env: o.env, platform: o.platform, exists: o.exists }), env: o.env, err: o.err };
3595
+ snapshotSpawn = directReadSpawn({ spawn: withDeclaredClient((c, a, opts) => o.spawn(c, a, opts)), ...readOpts });
3596
+ scanSpawn = directReadSpawn({ spawn: withDeclaredClient(defaultSpawnAsync), ...readOpts });
3543
3597
  }
3544
3598
  npm = discoverNpmUnits({ root: repo, tracked, readFile: readSnap, exists: o.exists });
3545
3599
  npmRoots = discoverPnpmRoots({ root: repo, tracked, exists: o.exists });
@@ -3620,7 +3674,9 @@ export async function main(argv, io = {}) {
3620
3674
  // own workspace's checkout target directory or refuses it (RK_CARGO_*), whatever path reaches cargo. Direct POSIX hosts only (a
3621
3675
  // broker host never reaches here; a direct Windows host keeps the fail-closed static walk). Removed when the gate ends (cleanup).
3622
3676
  if (o.platform !== "win32" && rust.workspaces.length) {
3623
- const realCargo = realCargoOf(which("cargo"));
3677
+ // GC-A11: the wrapper runs non-compiling calls directly, so a broker-mode wrapper also needs the real toolchain cargo (shims off PATH).
3678
+ const directCargoBin = () => { const pk = o.platform === "win32" ? (Object.keys(o.env).find((k) => k.toUpperCase() === "PATH") ?? "Path") : "PATH"; const d = directCargoPath({ env: { ...o.env, PATH: o.env[pk] }, exists: o.exists, platform: o.platform, gateWrappers: true }); const names = o.platform === "win32" ? ["cargo.exe"] : ["cargo"]; for (const dir of String(d.path ?? "").split(o.platform === "win32" ? ";" : ":").filter(Boolean)) for (const n of names) { const c = path.join(dir, n); if (o.exists(c)) return c; } return null; };
3679
+ const realCargo = wrapBroker ? (directCargoBin() ?? undefined) : realCargoOf(which("cargo"));
3624
3680
  if (!realCargo && !wrapBroker) { o.err(`${tag} BLOCKED: no cargo on PATH to wrap for the snapshot compile; nothing compiled`); return 1; }
3625
3681
  try {
3626
3682
  const shimModule = await import("../cargo-target-shim.mjs");
@@ -3987,7 +4043,7 @@ export async function main(argv, io = {}) {
3987
4043
  toolPolicy: scanTools?.policy ?? {},
3988
4044
  // The class A cargo reads (deny, audit, metadata, fetch) are carried requests the broker executes (scanSpawn): cargo-deny and cargo-audit are then the
3989
4045
  // broker's registered tools, in the check stage too (which has no snapshotBroker). Only when the reads really go to the broker.
3990
- ...(executor.kind === "broker" && scanSpawn ? { cargoReadsCarried: true } : {}),
4046
+ // GC-A11: never carried; scanSpawn runs the class A cargo reads directly, and cargo-deny and cargo-audit are the gate's enrolled tools.
3991
4047
  // A broker host: every Cargo call a class A scan makes inside the snapshot is a carried read-only request; the gate's own temporary files
3992
4048
  // (cargo-deny's metadata and config) live in a gate-owned directory the broker can read, outside the checkout.
3993
4049
  ...(scanSpawn ? { spawn: scanSpawn, tmpRoot: gateTmpRoot({ env: o.env, platform: o.platform, exists: o.exists }) } : {}),
@@ -4097,8 +4153,9 @@ export async function main(argv, io = {}) {
4097
4153
  // A row answered from the scan cache compiled nothing in this run: the bytes in the target are not vouched for by it.
4098
4154
  if (r.status !== "pass" || r.cached === true || !/^test:rust(?:-serial)?:/.test(String(r.id))) continue;
4099
4155
  const unit = unitByName.get(rustUnitOfScanId(r.id, known));
4100
- if (snapshotBroker) addBins(r.snapshotEvidence?.outputDir, unit, r.snapshotEvidence?.targetTriple ? `${r.snapshotEvidence.targetTriple}/debug` : null);
4101
- else addBins(targetDirs?.[unit?.cargoDir ?? "."], unit, "debug");
4156
+ // Every compile is release for the host target (GC-A15, cross-targets.mjs oneOutput): the test executables are under <triple>/release.
4157
+ if (snapshotBroker) addBins(r.snapshotEvidence?.outputDir, unit, r.snapshotEvidence?.targetTriple ? `${r.snapshotEvidence.targetTriple}/release` : null);
4158
+ else addBins(targetDirs?.[unit?.cargoDir ?? "."], unit, `${hostTarget()}/release`);
4102
4159
  }
4103
4160
  const outputWorkspaces = (snapshotBroker ? scanResult.scans.map((r) => r.snapshotEvidence?.outputDir) : Object.values(targetDirs ?? {})).filter(Boolean).map((dir) => [dir]);
4104
4161
  o.onCompileOutputs?.(outputWorkspaces.map(([dir]) => dir), builtBins);