@rightkit/release 0.6.8 → 0.6.11

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 (96) hide show
  1. package/README.md +30 -12
  2. package/admission-files.mjs +4 -1
  3. package/admission.mjs +2 -2
  4. package/advisory-exceptions.mjs +41 -5
  5. package/android-play-upload.mjs +16 -15
  6. package/app-check.mjs +105 -27
  7. package/assemble-app-command.mjs +151 -0
  8. package/assembled-app.mjs +445 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/darwin/rightkit-swap.sign.json +6 -0
  11. package/bin/rightkit-gate.mjs +1608 -345
  12. package/broker-pins.mjs +152 -0
  13. package/build-launch.mjs +88 -0
  14. package/build-ownership.mjs +1231 -0
  15. package/build-release.mjs +82 -28
  16. package/cargo-contract.mjs +3 -1
  17. package/cargo-ledger.mjs +62 -0
  18. package/cargo-target-shim.mjs +238 -0
  19. package/class-a-launch-guard.mjs +126 -0
  20. package/cli/right-release.mjs +31 -5
  21. package/clippy-equivalence.mjs +202 -0
  22. package/clippy-route.mjs +543 -0
  23. package/codesign-shim.mjs +11 -1
  24. package/committed-source.mjs +39 -5
  25. package/crate-workspaces.mjs +87 -0
  26. package/create-mac-updater.mjs +14 -2
  27. package/deterministic-scans.mjs +997 -71
  28. package/development-signing.mjs +4 -1
  29. package/development.mjs +120 -21
  30. package/direct-receipt.mjs +135 -0
  31. package/door-policy.mjs +71 -6
  32. package/door.mjs +6 -6
  33. package/freshness-census.mjs +257 -0
  34. package/gate-executor.mjs +14 -2
  35. package/gate-stages.mjs +91 -15
  36. package/heavy-command.mjs +3 -2
  37. package/hook-retirement.mjs +65 -0
  38. package/init-command.mjs +1 -1
  39. package/install-app.mjs +11 -2
  40. package/install-assembled-app.mjs +423 -0
  41. package/inventory-check.mjs +11 -8
  42. package/ios-dev.mjs +70 -3
  43. package/ios-upload.mjs +20 -15
  44. package/linux-artifacts.mjs +93 -0
  45. package/local-publish.mjs +224 -53
  46. package/local-qualify.mjs +5 -3
  47. package/local-runtime.mjs +5 -4
  48. package/mac-package-recipe.mjs +4 -1
  49. package/managed-cargo.mjs +47 -5
  50. package/managed-root-guard.mjs +34 -0
  51. package/mirror-root-artifact.mjs +18 -5
  52. package/native-helpers.mjs +355 -0
  53. package/ownership-class-rules.mjs +1 -1
  54. package/ownership-evidence.mjs +37 -13
  55. package/ownership-hooks.mjs +20 -59
  56. package/ownership-lockfiles.mjs +14 -1
  57. package/ownership-policy-bundle.mjs +3 -2
  58. package/ownership-staged.mjs +2 -1
  59. package/ownership.mjs +24 -0
  60. package/package-recipe-targets.mjs +84 -10
  61. package/package.json +4 -3
  62. package/pipeline-admission.mjs +4 -4
  63. package/pipeline-receipts.mjs +302 -302
  64. package/preflight.mjs +27 -4
  65. package/project-type.mjs +2 -2
  66. package/provenance-verify.mjs +115 -0
  67. package/publish-cargo.mjs +12 -9
  68. package/qa-admission-policy.json +10 -1
  69. package/recipe-build.mjs +106 -14
  70. package/release-artifact-path.mjs +341 -0
  71. package/release-config.mjs +1 -1
  72. package/release-invocation.mjs +8 -1
  73. package/release-qa-command.mjs +25 -9
  74. package/release-source-brand.mjs +17 -0
  75. package/release-source-identity.mjs +171 -0
  76. package/release.mjs +59 -22
  77. package/rightkit-versions.json +73 -47
  78. package/runtime-budgets.mjs +6 -2
  79. package/scan-digest.mjs +71 -5
  80. package/sign-updater.mjs +6 -3
  81. package/snapshot-broker.mjs +172 -0
  82. package/snapshot-check.mjs +312 -0
  83. package/snapshot-git.mjs +166 -0
  84. package/snapshot-request.mjs +363 -0
  85. package/snapshot-root-config.mjs +76 -0
  86. package/snapshot.mjs +969 -0
  87. package/source-split.mjs +73 -0
  88. package/stage-commands.mjs +60 -0
  89. package/stage-ledger.mjs +204 -0
  90. package/stage-sidecar-command.mjs +5 -1
  91. package/stage-sidecar.mjs +157 -27
  92. package/swap-helper-bundle.mjs +5 -2
  93. package/test-command.mjs +17 -4
  94. package/unit-gate.mjs +301 -0
  95. package/web-build.mjs +40 -6
  96. package/win-package-recipe.mjs +20 -7
package/README.md CHANGED
@@ -102,7 +102,7 @@ On desktop (non-iOS) targets the source walk covers the whole app root, so app t
102
102
 
103
103
  `right-release check [--repo <dir>] [--config <file>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]`
104
104
  is the offline app check, the same as `rightkit-gate check`: it reads files only (the committed tree of `--commit`, default HEAD, or with
105
- `--worktree` the tree on disk, which is what a compile would see), runs every rule and prints every finding in one run. Exit 0 passes,
105
+ `--worktree` the tree on disk, a developer's own run that never admits a build; a compile reads the commit snapshot, `--snapshot`), runs every rule and prints every finding in one run. Exit 0 passes,
106
106
  1 has findings, 2 could not run (a repository with no commit yet says `the repository has no commit yet; commit first`). The repository
107
107
  is the git top-level of `--config`'s folder, else of `--app-root` (`recipe-build`), else of the working directory.
108
108
 
@@ -112,8 +112,8 @@ only change what a command does after the check.
112
112
 
113
113
  | Mode | Commands |
114
114
  |---|---|
115
- | `commit` (HEAD: what is built, packaged, signed and uploaded) | `release`, `build` (also `build --web` and the direct-flag form `right-release --platform ...`), `package`, `web-build`, `swift-package`, `recipe-build`, `qa`, `upload`, `publish`, `publish-update`, `github`, `sign-windows`, `sign-updater`, `create-mac-updater`, `sign-candidate`, `addon build` |
116
- | `worktree` (the tree on disk, as a compile sees it) | `dev`, `doctor`, `test`, `stage-sidecar`, `sign-dev-artifact` |
115
+ | `commit` (HEAD: what is built, packaged, signed and uploaded) | `release`, `build` (also `build --web` and the direct-flag form `right-release --platform ...`), `package`, `web-build`, `swift-package`, `recipe-build`, `assemble-app`, `qa`, `upload`, `publish`, `publish-update`, `github`, `sign-windows`, `sign-updater`, `create-mac-updater`, `sign-candidate`, `addon build` |
116
+ | `worktree` (the tree on disk: interim; a compile reads the commit snapshot, `--snapshot`) | `dev`, `doctor`, `test`, `stage-sidecar`, `sign-dev-artifact` |
117
117
 
118
118
  Not guarded: the report forms of guarded commands (`publish cargo|npm|swift`, `qa audit`, `doctor --all`) and every other command.
119
119
  Each of those is listed with its reason in `UNCHECKED_COMMANDS` (`app-check.mjs`), and `app-check.test.mjs` fails a command that is in
@@ -201,9 +201,26 @@ it. Past its sunset an entry stops tolerating its finding (the finding fails wit
201
201
  `EXPIRED`; a listed identity that no finding has any more is `STALE` and fails, so the list only shrinks. A rule the build refuses
202
202
  whatever a baseline says (`switches-inventory`) fails even when listed.
203
203
 
204
- ## The pre-push gate on a host without the broker: the Rust gate is deferred, the HOST RECEIPT gates the merge
204
+ ## Every build tool is a door
205
205
 
206
- The gate chooses its executor itself (`gate-executor.mjs` `resolveExecutor`), never the caller: the RustRight broker where the machine has it (`rustright` on PATH, or its managed cargo shim), a refusal on a broker-managed machine whose broker cannot be reached, and direct `cargo` on any other host (a cloud session, a CI runner). Every host runs the same gates in the same order (app rules, class A, then the compile scans); an unmanaged host is never sent to a broker and nothing is deferred to one. Every pushed ref is judged in any position (git's stdin is split on single ASCII spaces, so a Unicode space in a ref name cannot hide one), and a stdin line that is not four fields refuses the push on every host and in every repository, because git sends the local field as typed and a rev expression such as `HEAD^{/one thing}` holds spaces: such a line is never dropped, so the pushed commit is always compared with HEAD.
206
+ The agent hook (`door-policy.mjs`, installed by `rightkit-gate install-hook`) denies an agent's shell command that builds an app any way but the door: cargo's compiling subcommands, `tauri build|dev|bundle|android|ios`, `right-release` stages, `xcodebuild` (every action; a bare call builds), `xcrun` wrapping them, `swift build|test|run`, `swiftc`, Gradle tasks other than the read-only reports, `vite build` and the bundlers, and a package script whose name says it builds. `rustright cargo …` stays allowed: the broker runs class A before it admits a compile. The denial names the stage and platform to run.
207
+
208
+ `--platform ios|android` are door platforms: check, compile, package (`right-release build`, the signed sealed IPA or AAB), then `--to release` uploads to the store's test track from the host (`right-release upload --apply`; TestFlight, Play internal) after the release tier passes. No mobile qualify stage exists or is recorded; the test track is the qualification.
209
+
210
+ ## Rule tiers: compile and release
211
+
212
+ Adrian, 2026-10-26: the apps lost every build route on 2026-10-25 and build again through the one door on the rule set they were last green on, before any newer rule is asked of them. `app-check.mjs` `TIERS`:
213
+
214
+ | Tier | Rules | Owed by |
215
+ |---|---|---|
216
+ | `compile` (default) | the 13 rules @rightkit/release 0.6.6 published (`COMPILE_TIER_IDS`) | compile admission: the RustRight broker, `rightkit-gate check`, `--to compile\|package`, `right-release build\|dev\|test\|doctor` |
217
+ | `release` | every rule, the ones added since 0.6.6 included (C3, SC, CA, LG, SF-01, OW-05, OW-06), plus the `shellcheck` scan and actionlint's shellcheck requirement | `rightkit-gate --to qualify` (it runs `check --rule-tier release` before installed QA and records `ruleTier` in the receipt) and `right-release release\|qa\|upload\|publish\|publish-update\|github\|sign-*\|create-mac-updater\|sign-candidate` (`RELEASE_TIER_COMMANDS`) |
218
+
219
+ `rightkit-gate check --rule-tier compile|release` selects the tier (`--tier patch|update` is the upload tier); `--list` names each rule's tier; `--only` is a view over the whole set. A rule is promoted to the compile tier by moving its id into `COMPILE_TIER_IDS` in RightKit, never per app and never through a baseline. RightKit's own preflight runs every rule.
220
+
221
+ ## The gate on a host without the broker, and tests that reach it
222
+
223
+ The gate chooses its executor itself (`gate-executor.mjs` `resolveExecutor`), never the caller: the RustRight broker on a managed Mac or Dell (chosen by host identity, not by `rustright` on PATH), a refusal on a broker-managed machine whose broker cannot be reached, and direct `cargo` on every Linux host and any other host (a cloud session, a CI runner). Every host runs the same gates in the same order (app rules, class A, then the compile scans); an unmanaged host is never sent to a broker and nothing is deferred to one. Commit and push run no gate and no local hook check, and `install-hook` writes no git hook (below); when git's ref lines are nevertheless given on stdin (the form a pre-push hook receives), every ref is judged in any position (split on single ASCII spaces, so a Unicode space in a ref name cannot hide one), and a line that is not four fields is refused, never dropped, so the pushed commit is always compared with HEAD.
207
224
 
208
225
  `scriptBrokerCall` reads `package.json` scripts only: it follows `pnpm <script>`, `pnpm run`, `npm run`, `yarn` and the wrapper words, not `node -e`, a node script, `sh -c`, or a run in another package (`pnpm --dir`, `--filter`, `-r`). A unit whose test reaches the broker that way says so in the app's own `ci/gate.config.json`, in the `npm` entry the unit's `quietTests` already uses (the key is the unit name: the package name, or the directory with `/` as `-` for a unit of a pnpm root below the repository root):
209
226
 
@@ -214,9 +231,11 @@ The gate chooses its executor itself (`gate-executor.mjs` `resolveExecutor`), ne
214
231
 
215
232
  The `reason` is required and is printed. The file is read from the gated commit, so the declaration is a reviewed change in the diff that adds it. Where the Rust gate is deferred, the unit's `test:npm:<unit>` scan is deferred with it and named `declared in ci/gate.config.json npm.<unit>.requiresBroker: <reason>`, never reported as passed; on a host with the broker, in CI, on a protected ref and with the opt-out it runs as it did. A declaration of any other shape (`true`, a string, an empty or blank `reason`, an array) is ignored with a note and the test runs where it can: a malformed entry never skips a test. `false` or `null` is no declaration, and a call the scripts do show is reported from the scripts. The gate does not read node scripts for a literal `rustright` or `cargo`: a literal cannot tell a spawn from a command table, a mention or a test fixture, so the declaration is the way.
216
233
 
217
- ## The pre-push hook finds the gate in a repository whose root installs nothing
234
+ ## `install-hook` writes no git hook, and retires the ones an earlier release wrote
218
235
 
219
- `rightkit-gate install-hook` writes `.githooks/pre-push` (and sets `core.hooksPath`). The hook runs the repository's own `.git/hooks/pre-push`, then `rightkit-gate check --commit HEAD`, then the gate, and finds the gate binary in `node_modules/.bin/rightkit-gate` of the repository root, else of any workspace package up to three levels down, else through `pnpm exec`, else with `node` at a path written into the hook. That path is always relative to the repository, the same for every version and a plain name, never absolute, never `../` out of the repository, never a `node_modules/.pnpm/@rightkit+release@x.y.z/` store path, and it is decided by what the tracked `package.json` files declare, never by what is installed: the same repository gets the same hook on a fresh clone, from an exports checkout, after `pnpm install` and after a version bump, so `install-hook` as a `prepare` script never meets a hook it wrote earlier as one that "already exists and differs". When the root `package.json` declares `@rightkit/release` (alone, or together with packages: Pulse declares it at the root and in `hub/`), the path is the root's `node_modules/@rightkit/release/bin/rightkit-gate.mjs` and the hook is the single-root form. When only packages declare it (CutRight's `apps/cutright`, HeardRight's `tauri-app-next`), the path is the same link in the first of them in sorted order, whichever is installed, the hook also tries each declaring package's `node_modules/.bin/rightkit-gate` directly (deeper than the glob reaches) and it runs the `pnpm` fallback as `pnpm --dir <package> exec rightkit-gate`, because `pnpm exec` at a root that does not declare the package has no `rightkit-gate` to run. When no `package.json` declares it, the gate's own path inside the repository if it runs from there and not from a `node_modules` (a checkout of the package itself), else the root's link. A declaring package whose directory is not made of letters, digits and `_ - . @ +` is named in a warning and left out. A root-install repository gets, byte for byte, the hook that 0.6.4 and 0.6.5 wrote (`fixtures/published-gate-hooks.json`); a multi-root repository whose committed hook still has a baked path regenerates it once with `rightkit-gate install-hook --force`, and the result no longer changes with the version or the install.
236
+ Commit and push run no gate and no local hook check (`docs/GATE-CONTRACT.md`: the gate is for building; CI runs preflight and the full check on every push). `rightkit-gate install-hook` (a `prepare` script; `right-release hooks install` is an alias) registers the one-door agent hook in `.claude/settings.json` and writes no git hook: no `.githooks/pre-push`, no forwarder for the hooks in `.git/hooks`, no pre-commit ownership step, and it no longer sets `core.hooksPath`.
237
+
238
+ It removes what an earlier release wrote, each file matched by its own text only: `.githooks/pre-push` by its header (line 2: `# Mandatory local [check and] gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.`), a `.githooks/<name>` forwarder by its header naming that same hook, and the ownership pre-commit hook by the marker `rightkit-ownership-precommit v1` in its first three lines (in `.githooks`, in `.git/hooks`, or in the directory `core.hooksPath` names); the foreign pre-commit the ownership step kept as `pre-commit.rightkit-foreign` goes back to `pre-commit`. A foreign hook, a symbolic link, a directory and any file whose header differs are never read for change, moved or deleted. It unsets the repository's own `core.hooksPath` only when its value is exactly `.githooks` and nothing foreign remains in that directory (an emptied `.githooks` is removed, and the hooks in `.git/hooks` run again); otherwise it leaves the setting and says so. The global and system git configuration is never written. `--force` is accepted and ignored: there is nothing to overwrite. `right-release ownership --staged` stays as a manual command; broker admission and the release gate remain the authoritative ownership checks. An app deletes its committed `.githooks/pre-push` after the release carrying this change is published.
220
239
 
221
240
  ## The rustfmt scan reads each file's edition from its crate
222
241
 
@@ -391,9 +410,7 @@ Hardening, applied in both modes (a failure is a warning in `warn` and a refusal
391
410
 
392
411
  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.
393
412
 
394
- ## Local publish (`publish npm|cargo ... --local`, Mac or Windows)
395
-
396
- `right-release publish cargo --crate <name> --local` (and the batch form `--crates a,b,c | --crates-file <file>`) publishes from the Mac **or from Windows** under the committed local-build grant (`.rightkit-local-build.json`, `allow: "build-test-publish"`, naming this repository); `publish npm --package @rightkit/<name> --local` stays Mac only (the npm token comes from the macOS Keychain and `@rightkit/release` ships a signed helper), and `--local` refuses any other host (Linux) and any host without the grant. The gate is the same on both hosts: the unit's own `ci/gate.config.json` commands run with zero failures and no skipped evidence, the artifact sha256 is recorded, and the existing `enforcePublicationGate` and publisher run, including the registry readback. The evidence carries one receipt for the publishing host: `hostPolicy: "darwin-only"` with a `darwin` run on the Mac, `hostPolicy: "win32-only"` (plus a `hostPolicyReason`) with a `win32` run on Windows; the gate requires that host's receipt and `isDarwinOnlyPolicy`, `isWin32OnlyPolicy` and `singleHostOfPolicy` in `publication-gate.mjs` are the one definition. A crate whose `ci/gate.config.json` `os` list lacks the publishing host (`macos` or `windows`) is refused. Windows publishes with the credential Cargo stores itself (`~/.cargo/credentials.toml`): no `--token` is passed, read or logged. A runtime-budget crate needs the measurement of the publishing host only, under the same evidence policy that the budget file may not contradict.
413
+ ## Gate consumer rows
397
414
 
398
415
  `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.
399
416
 
@@ -407,7 +424,7 @@ right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id d
407
424
 
408
425
  It mounts the image read-only and hidden (`-nobrowse`), verifies the app (`codesign --verify --deep --strict`, `spctl --assess`, `stapler validate`, bundle id, and Team ID against the installed copy; a Team ID change is refused) before touching the installed copy, stages a `ditto` copy beside it in the destination and verifies that copy, quits the running instance with SIGTERM (15 s, then SIGKILL), swaps the staged copy in (one atomic exchange when the `rightkit-swap` helper is found, otherwise two journaled renames; the old bundle is restored on failure), re-verifies the installed copy, only then moves the old bundle to `~/.Trash/<App> <timestamp>.app`, and relaunches only if it was running or `--relaunch` is given, using `open -g -j -b` (background, hidden, never activated). `--json` emits one lifecycle event per line (`start`, `verified`, `quit`, `replaced`, `relaunched`, `done`/`failed`, among others). `right-release install-app --recover [--dest <dir>] [--app-name <App.app>] [--json]` rolls an interrupted install forward or back from its journal; every install runs that recovery first. Win32 exits with "not implemented on win32". Exit codes: 0 ok, 1 failed, 2 usage, 3 unsupported platform.
409
426
 
410
- The shipped `bin/darwin/rightkit-swap` is Developer ID signed. Local publish adds it via `right-release publish npm --package @rightkit/release --local` on the Mac (managed `rustright cargo build --release -p rightkit-fs --bin rightkit-swap`, `codesign --options runtime --timestamp`, strict verify). CI qualification adds it via `publish.yml` build-swap → sign-swap → pack (`docs/CI.md`); sign-swap needs `APPLE_CERTIFICATE_BASE64`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_KEYCHAIN_PASSWORD` & `APPLE_DEVELOPER_ID` in the `release` environment, & stays blocked until root provisions the certificate pair. Exporting that identity requires an Aqua login session: SSH export fails `errSecInteractionNotAllowed` (-25308) & the Aqua signing agent exposes only codesign/updater/notarize/staple, no key export — do not retry either route. `install-app` refuses a helper that exists but is not validly signed (`SWAP_HELPER_UNSIGNED`, naming the fix); with no helper at all it uses the journaled two-rename fallback and logs it (`swap-fallback`, `replaced {swap}`).
427
+ The shipped `bin/darwin/rightkit-swap` is Developer ID signed. CI qualification adds it via `publish.yml` build-swap → sign-swap → pack (`docs/CI.md`); sign-swap needs `APPLE_CERTIFICATE_BASE64`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_KEYCHAIN_PASSWORD` & `APPLE_DEVELOPER_ID` in the `release` environment, & stays blocked until root provisions the certificate pair. Exporting that identity requires an Aqua login session: SSH export fails `errSecInteractionNotAllowed` (-25308) & the Aqua signing agent exposes only codesign/updater/notarize/staple, no key export — do not retry either route. `install-app` refuses a helper that exists but is not validly signed (`SWAP_HELPER_UNSIGNED`, naming the fix); with no helper at all it uses the journaled two-rename fallback and logs it (`swap-fallback`, `replaced {swap}`).
411
428
 
412
429
  Development builds (from `@rightkit/ci` `devArtifact`, Developer ID-signed but not notarized) install with `--dev` and exactly one of `--dmg`, `--zip <path.zip>` (extracted with `ditto -x -k`), or `--app <path.app>`: `right-release install-app --dev --zip dev-mac.zip`. `--dev` still requires `codesign --verify --deep --strict`, a Team ID, no Team ID change, and a bundle-id match; it skips only `spctl` and `stapler`, removes `com.apple.quarantine` from the new bundle only, prints `DEV BUILD (not notarized)`, and sets `dev: true` on every JSON event.
413
430
 
@@ -574,7 +591,7 @@ Supported: `title`, `icon` (`.icns` volume icon), `background` (PNG or TIFF), `i
574
591
 
575
592
  ## One gate for GitHub uploads
576
593
 
577
- `right-release upload` and `right-release github --release` share one gate (`github-upload-gate.mjs`): both require `--tier patch|update`, the primary checkout with a clean tree, a sealed release built from the current HEAD with matching ownership evidence, and a release config that declares `distribution.provider: "github-releases"` and `distribution.repository` (`--repo` may only repeat it; the `origin` remote is never consulted). The gate makes no network call and also runs on `--dry-run`. Private products publish with `publish-update` and `distribution.provider: "r2-rightapps"`. `github --addon` publishes headless add-ons and takes no tier. Not gated the same way: `github --addon` (no tier), `addon upload` (no provider requirement), and the direct R2 uploaders `upload-large.mjs` and `pub-upload.mjs` (no tier, provider or clean-tree check).
594
+ `right-release upload` and `right-release github --release` share one gate (`github-upload-gate.mjs`): both require `--tier patch|update`, a sealed release built from the selected commit with matching ownership evidence, and a release config that declares `distribution.provider: "github-releases"` and `distribution.repository` (`--repo` may only repeat it; the `origin` remote is never consulted). The gate makes no network call and also runs on `--dry-run`. Private products publish with `publish-update` and `distribution.provider: "r2-rightapps"`. `github --addon` publishes headless add-ons and takes no tier. Not gated the same way: `github --addon` (no tier), `addon upload` (no provider requirement), and the direct R2 uploaders `upload-large.mjs` and `pub-upload.mjs` (no tier, provider or clean-tree check).
578
595
 
579
596
  ## GitHub Releases updater lane (`publish-update`, `github-releases`)
580
597
 
@@ -686,6 +703,7 @@ Any app that ships more than the ffmpeg sidecar (MailRight: `mailright-engine`,
686
703
  - **`cmake` (Orthic-Labs/rightkit#489):** `{ kind: "cmake", name: "whisper-cli", repo: "https://github.com/ggml-org/whisper.cpp", commit: "<40 hex>", target?: "whisper-cli", output?: "bin/whisper-cli", cmakeArgs?: ["-DBUILD_SHARED_LIBS=OFF"], platformArgs?: { mac: ["-DGGML_METAL=ON"], win: [...] }, generator?, smoke?: ["--help"], sha256?, dest?, clean?, platforms? }`. It fetches exactly that commit into the fixed `.right-release/cmake/<name>/src` (`git init`, `fetch --depth 1 origin <commit>`, detach; a checkout already at the commit is reused, one with local edits or at another commit is refused with `STAGE_SIDECAR_SOURCE_MISMATCH`), configures and builds `target` in `.right-release/cmake/<name>/build` (`CMAKE_OSX_ARCHITECTURES` is set from the target; `universal-apple-darwin` builds `arm64;x86_64` and checks `lipo -archs`), finds the executable (`output`, then `Release/` layouts, `.exe` on Windows), optionally pins its sha256, stages it as `<name>-<triple>[.exe]` and smoke-runs it when the target can run on this host. `cmakeArgs` and `platformArgs` accept only `-DNAME=value` definitions. Toolchain controls (#691, all optional, defaults unchanged): `cmakeBin: "<name or path>"` runs that cmake (a bare name is looked up on PATH, a path with separators is resolved against the app root); `generator` is per entry and `platformGenerators: { win: "Visual Studio 17 2022" }` overrides it for one platform (`-G`); `architecture: "x64"` (any `-A` platform: `x64`, `Win32`, `ARM64`) adds `-A <arch>` after `-G`. With no `cmakeBin`, a Windows host uses `cmake` from PATH (`where cmake`) when there is one, else the Visual Studio-bundled cmake (`vswhere.exe` under `%ProgramFiles(x86)%\Microsoft Visual Studio\Installer`, `-latest -products * -requires Microsoft.VisualStudio.Component.VC.CMake.Project -property installationPath`, then `Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe`), else plain `cmake`. The receipt row carries `cmakeBin` (when not plain `cmake`) and `generator`. There is no environment-variable override: the staging recipe has none for any other tool.
687
704
  - **`prebuilt` (#490):** verifies a host-built binary already staged at `<stageDir>/<name>-<triple>` (or copied from `from`) against an app manifest, never building or downloading: `manifest: { file, entry: "binaries.{platform}", sha256?: "sha256", approvalSha256?: "approval_sha256", approvalAlgorithm?: "approval_hash_algorithm" }` (`{platform}` is `macos-aarch64`, `windows-x86_64`, ...). The raw sha256 must match; when an approval hash is named it must match too, with `raw-sha256` or `macho-codesign-strip-linkedit-v1` (`macho-approval-hash.mjs`: the sha256 of a thin Mach-O with its code signature removed and `__LINKEDIT` normalised, so a signed and an unsigned copy of one build agree; macOS only, `codesign --remove-signature` on a private copy). A missing file, entry or digest fails closed. With `from: { aarch64: "<path>", x86_64: "<path>" }` the entry also works on `universal-apple-darwin` (#692): each arch is verified against its own manifest entry (`macos-aarch64`, `macos-x86_64`; the approval algorithm default is the macOS one), then both are lipo-merged by the same helper `ffmpeg`, `onnxruntime` and `cmake` use into `<name>-universal-apple-darwin` (both archs required, `STAGE_SIDECAR_NOT_UNIVERSAL` otherwise; nothing is merged after a failed slice). With `from` omitted the inputs are the staged `<name>-aarch64-apple-darwin` and `<name>-x86_64-apple-darwin`; a plain string `from` is refused for universal. On a thin target an object `from` picks the entry for that arch. The row carries `platform: "macos-universal"` and per-arch `slices`.
688
705
  - **`remote` archives (#491):** `extract: "<member file name>"` (with `memberSha256?`, `executable?`) unpacks one member of a zip or tgz after the archive digest passes, so a Windows zip needs no `command` entry. A bare name must be the base name of exactly one member; a name with `/` (`bin/x64-win/DirectML.dll`) must equal exactly one member path (case-sensitive), for archives that repeat a base name in several directories. Absolute, drive-prefixed, backslash, empty, `.`/`..` segment and glob-character paths are refused, and the output stays at `dest`.
706
+ - **`remote` NSIS installers:** `extract: "bin/smartctl.exe", extractKind: "nsis", memberSha256: "<member sha256>"` (memberSha256 required) runs 7-Zip (`7z` on PATH, `7z.exe` under Program Files on win32; `RIGHTKIT_SEVEN_ZIP` or `deps.sevenZip` overrides) as `x -y -o<tmp> <installer> <member>` after the installer digest passes, verifies the member sha256, and stages it. A missing 7-Zip, failed extraction or any digest mismatch fails closed naming the installer sha, member, expected and actual digests. The receipt row records `memberSha256`, `archiveSha256`, `member` and `extractKind`.
689
707
  - **`ffmpeg` mixed variants, fetch and probes (#491):** `variants: { ffmpeg: "export-ass" }` lets one entry take the export-ass ffmpeg with the decode ffprobe (a tool not named uses `variant`). `sources: { ffmpeg: { url, sha256, member }, ffprobe: {...} }` downloads the bytes the catalog only pins: the archive is digest-checked, the member is unpacked and its sha256 must equal the APPROVED catalog row's content digest, so a swapped archive can never stage. A source may instead be a local path (#692): `sources: { ffmpeg: { path: "../vendor/ffmpeg-lgpl", member?: "ffmpeg" } }` (also per arch: `{ aarch64: { path }, x86_64: { path } }`). `path` is relative to the app root and may step out of it; it names the binary itself or a directory holding `<tool>[.exe]` or `bin/<tool>[.exe]` (`member` renames the file). Nothing is downloaded and there is no archive digest, but the file must still equal the APPROVED catalog row content digest (`STAGE_SIDECAR_HASH_MISMATCH` otherwise); notices, probe and universal lipo are unchanged; a missing file is `STAGE_SIDECAR_BINARY_NOT_FOUND`. `probe: true` runs `-version` and `-encoders` on a staged binary this host can run: the configuration must say `--disable-gpl` and `--disable-nonfree` and must not enable GPL, nonfree, libx264, libx265, libxvid or libfdk-aac, and ffmpeg may not list those encoders (`STAGE_SIDECAR_FFMPEG_PROBE_FAILED`; another architecture or OS is recorded as `skipped-cross-target`). The catalog has no Windows `export-ass` row yet (only `export-ass-zimg`); that row belongs to the rightkit-ffmpeg crate.
690
708
  - **`native-library` (plan item 2.8):** `{ kind: "native-library", library: "onnxruntime" | "directml" | "pdfium" | "whisper-cpp" | "ggml" | "sherpa-onnx", version, catalog, resourceDir?, licenseDir?, cacheDir?, as?, files?, companions?, source?: { dir }, platforms? }` stages the `approved` or `pinned` rows of `native-libraries.json` for the target (every serving row, or the `files` named) into `resourceDir`, with their notices and a receipt row per file. The bytes come from the row's digest-pinned archive or, for a row with no archive (the macOS whisper.cpp, ggml and sherpa-onnx builds), from `source: { dir }`; an archive file must match the whole-file sha256 and the content digest, a `source.dir` file the content digest (a re-signed copy passes, changed code does not, and a thin row refuses a universal Mach-O), and the staged bytes are checked again after the copy. A row's `companions` (the Windows onnxruntime row names DirectML) are staged beside it with a `companionOf` receipt row; `companions: false` opts out, and a file another entry also writes into `resourceDir` is refused before anything is written. A row with `hostProof: "never-run"` stages with a warning (stderr on the CLI) and carries the marker into the receipt. `kind: "onnxruntime"` stays the alias of `native-library` with `library: "onnxruntime"`. See `docs/STAGE-SIDECAR.md`.
691
709
  - **`ffmpeg` `resourceDir`:** `{ kind: "ffmpeg", resourceDir: "src-tauri/resources/runtime", ... }` stages `<resourceDir>/ffmpeg[.exe]` and `ffprobe[.exe]` (no target triple, not an externalBin; an already-staged file that matches its row is made executable) from the same approved rows, notices, `sources`, probe and universal lipo merge as the externalBin layout; `bundleDir` and `resourceDir` cannot be combined.
@@ -46,7 +46,7 @@ export const FILES_ROWS = Object.freeze([
46
46
  tier: "files",
47
47
  run(ctx) {
48
48
  if (ctx.platform === "android" || ctx.platform === "ios") return { na: "android and ios build without a package command" };
49
- if (ctx.target.package || packageCommandOptional(ctx.target, ctx.projectType())) return undefined;
49
+ if (ctx.target.package || packageCommandOptional(ctx.target, ctx.projectType(), { expanded: true })) return undefined;
50
50
  return [`${app(ctx)} has no ${ctx.platform} package command`];
51
51
  },
52
52
  },
@@ -69,6 +69,9 @@ export const FILES_ROWS = Object.freeze([
69
69
  run(ctx) {
70
70
  const inputs = ctx.buildInputs();
71
71
  if (!inputs.ok) return { skipped: "build-inputs is reported on its own" };
72
+ // A door source split (row 7.04): `repoRoot` is then the snapshot, the commit's bytes verified by the door at the start and end of the
73
+ // stage, and there is no checkout cleanliness to ask. Every input resolved under it by construction.
74
+ if (ctx.releaseSource?.mode === "snapshot") return undefined;
72
75
  const dirty = dirtyBuildInputs(ctx.repoRoot, inputs.value.buildInputs, ctx.commit());
73
76
  return dirty.length > 0 ? [`dirty files can change the packaged app; resolve them before release:\n${dirty.map((file) => `- ${file}`).join("\n")}`] : undefined;
74
77
  },
package/admission.mjs CHANGED
@@ -60,7 +60,7 @@ function gitHead(repoRoot, spawn) {
60
60
  export async function runAdmission({
61
61
  root, repoRoot = root, configPath, config, platform, target, tiers = TIERS, env = process.env, spawn = spawnSync, registry,
62
62
  log = () => {}, hostPlatform = process.platform, mode = "build", skipChecks = false, skipDoctorOwned = false, commit,
63
- fs = { existsSync, readdirSync, readFileSync, statSync }, feed, rows = ADMISSION_ROWS, resolveInputs = resolveBuildInputs,
63
+ fs = { existsSync, readdirSync, readFileSync, statSync }, feed, rows = ADMISSION_ROWS, resolveInputs = resolveBuildInputs, releaseSource = null,
64
64
  } = {}) {
65
65
  const findings = [];
66
66
  const results = [];
@@ -78,7 +78,7 @@ export async function runAdmission({
78
78
  const exists = fs.existsSync;
79
79
  const projectType = memo(() => resolveProjectType({ appRoot: root, config, target }));
80
80
  const ctx = {
81
- root, repoRoot, configPath, config, platform, target, env, spawn, registry, log, hostPlatform, mode, skipChecks, fs, exists, feed, out,
81
+ root, repoRoot, releaseSource, configPath, config, platform, target, env, spawn, registry, log, hostPlatform, mode, skipChecks, fs, exists, feed, out,
82
82
  // The target as the config declares it: the build controller judges a few rules on it, the worker on the expanded one.
83
83
  rawTarget: config?.targets?.[platform],
84
84
  projectType,
@@ -1,9 +1,11 @@
1
- // Advisory exception gate. ci/advisory-exceptions.json is the single source; deny.toml
1
+ // Advisory exception gate. Every crate is its own Cargo workspace (rightkit#1285), and a crate that ignores an advisory keeps its
2
+ // own single source, crates/<crate>/.rightkit/advisory-exceptions.json (CRATE_EXCEPTIONS_FILE): the crate's deny.toml
2
3
  // [advisories].ignore and .cargo/audit.toml must match it exactly, every entry needs an owner,
3
4
  // evidence and an unexpired absolute reviewBy date, and the entry's versions must match what
4
- // Cargo.lock actually holds, `dependents` must equal the exact set of direct reverse
5
- // dependencies of the affected versions in Cargo.lock, and every hop of `path` must be a real
6
- // dependency edge (scope drift fails in both directions). Policy: Astra 2026-10-22.
5
+ // the crate's Cargo.lock actually holds, `dependents` must equal the exact set of direct reverse
6
+ // dependencies of the affected versions in that Cargo.lock, and every hop of `path` must be a real
7
+ // dependency edge (scope drift fails in both directions). A crate with no ignore needs no ledger.
8
+ // There is no repository-level ledger. Policy: Astra 2026-10-22.
7
9
  import fs from 'node:fs';
8
10
  import path from 'node:path';
9
11
  import { fileURLToPath } from 'node:url';
@@ -12,6 +14,8 @@ export const EXCEPTIONS_FILE = 'ci/advisory-exceptions.json';
12
14
  export const DENY_FILE = 'deny.toml';
13
15
  export const AUDIT_FILE = '.cargo/audit.toml';
14
16
  export const LOCK_FILE = 'Cargo.lock';
17
+ /** The per-workspace ledger, relative to the workspace root (a crate directory). */
18
+ export const CRATE_EXCEPTIONS_FILE = '.rightkit/advisory-exceptions.json';
15
19
 
16
20
  const REQUIRED = ['id', 'crate', 'path', 'owner', 'rationale', 'evidence', 'reviewBy', 'approvedBy'];
17
21
  const ID_RE = /^RUSTSEC-\d{4}-\d{4}$/;
@@ -359,12 +363,44 @@ export function checkAdvisoryExceptions({ root = process.cwd(), now = new Date()
359
363
  return problems;
360
364
  }
361
365
 
366
+ // ---- per crate ----
367
+
368
+ // A non-empty `ignore = [...]` list (an empty list followed by a later `]` is not one).
369
+ const IGNORE_LIST_RE = /\bignore\s*=\s*\[[^\]]*[^\]\s][^\]]*\]/s;
370
+
371
+ /**
372
+ * Every crate workspace of the repository (crates/<x> holding a Cargo.toml) checked against its own ledger. A crate whose deny.toml or
373
+ * .cargo/audit.toml ignores an advisory must have its ledger; a crate with the ledger is checked in full against its own deny.toml,
374
+ * .cargo/audit.toml and Cargo.lock; a crate with neither is fine. Problems are prefixed with the crate directory.
375
+ * `io` = { readFile(absPath), readdir(absDir), exists(absPath) }, for tests.
376
+ */
377
+ export function checkCrateAdvisoryExceptions({ root = process.cwd(), now = new Date(), allowPending = false, io = {} } = {}) {
378
+ const readFile = io.readFile ?? (p => fs.readFileSync(p, 'utf8'));
379
+ const readdir = io.readdir ?? (d => fs.readdirSync(d));
380
+ const exists = io.exists ?? fs.existsSync;
381
+ const cratesDir = path.join(root, 'crates');
382
+ if (!exists(cratesDir)) return [];
383
+ const problems = [];
384
+ for (const entry of [...readdir(cratesDir)].map(String).sort()) {
385
+ const dir = `crates/${entry}`;
386
+ if (entry.startsWith('.') || !exists(path.join(root, dir, 'Cargo.toml'))) continue;
387
+ const here = path.join(root, dir);
388
+ const ignores = [DENY_FILE, AUDIT_FILE].some(f => { try { return IGNORE_LIST_RE.test(readFile(path.join(here, f))); } catch { return false; } });
389
+ if (!exists(path.join(here, CRATE_EXCEPTIONS_FILE))) {
390
+ if (ignores) problems.push(`${dir}: ${DENY_FILE} or ${AUDIT_FILE} ignores an advisory but ${CRATE_EXCEPTIONS_FILE} does not exist`);
391
+ continue;
392
+ }
393
+ for (const p of checkAdvisoryExceptions({ root: here, now, allowPending, exceptionsFile: CRATE_EXCEPTIONS_FILE, readFile: f => readFile(path.join(here, f)) })) problems.push(`${dir}: ${p}`);
394
+ }
395
+ return problems;
396
+ }
397
+
362
398
  // ---- CLI ----
363
399
 
364
400
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
365
401
  const args = process.argv.slice(2);
366
402
  const root = path.resolve(import.meta.dirname, '..', '..');
367
- const problems = checkAdvisoryExceptions({ root, allowPending: args.includes('--allow-pending') });
403
+ const problems = checkCrateAdvisoryExceptions({ root, allowPending: args.includes('--allow-pending') });
368
404
  if (problems.length) {
369
405
  for (const p of problems) console.error(`advisory-exceptions: ${p}`);
370
406
  console.error(`advisory-exceptions: ${problems.length} problem(s)`);
@@ -23,15 +23,14 @@
23
23
  import { createSign } from "node:crypto";
24
24
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
25
25
  import path from "node:path";
26
- import { spawnSync } from "./background-process.mjs";
27
26
  import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
28
27
  import { describeAabProblems, scanAabBuffer } from "./android-aab-scan.mjs";
29
28
  import { sealedManifestAllowsContentDevtools } from "./devtools-policy.mjs";
30
29
  import { targetDistribution } from "./target-distribution.mjs";
31
30
  import { AndroidReleaseError, createAndroidCredentialStore, resolveAndroidSecret, validateSecretSource } from "./android-release.mjs";
32
31
  import { assertArtifactUnchanged, digestBytes, verifyPublicationRelease } from "./publication-artifacts.mjs";
33
- import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
34
- import { assertCleanSource } from "./source-gate.mjs";
32
+ import { assertBindsSource, assertInsideSource, assertReleaseSource, gitRootOf, isSnapshotSource, resolveReleaseSource } from "./release-source-identity.mjs";
33
+ import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
35
34
 
36
35
  export const PLAY_PROVIDER = "google-play";
37
36
  export const PLAY_TRACK = "internal";
@@ -183,26 +182,28 @@ export async function uploadToPlay({ distribution, aabBytes, accessToken, fetchI
183
182
  }
184
183
 
185
184
  // ---- authorisation and the command --------------------------------------------------------------------------------
186
- const git = (cwd, args) => {
187
- const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
188
- if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
189
- return result.stdout.trim();
190
- };
191
185
 
192
186
  // Local checks only; no network, no secret. The sealed release is the only input: build-free by construction.
193
- export async function authorizePlayUpload({ releaseId, cwd = process.cwd(), configName = defaultReleaseConfigFile(cwd) }) {
187
+ export async function authorizePlayUpload({ releaseId, cwd = process.cwd(), configName = defaultReleaseConfigFile(cwd), env = process.env }) {
194
188
  if (!RELEASE_ID.test(releaseId ?? "")) refuse("PLAY_RELEASE_REQUIRED", "--release <sealed-id> is required");
195
- const repoRoot = git(cwd, ["rev-parse", "--show-toplevel"]);
196
- assertPrimaryReleaseCheckout(repoRoot);
197
- const commit = git(repoRoot, ["rev-parse", "HEAD"]);
198
- assertCleanSource({ status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId: "right-release upload" });
189
+ // Source identity (release-source-identity.mjs, item 7 row 7.04): the checkout's clean HEAD outside a door run, the selected commit and
190
+ // its verified snapshot inside one (HEAD, status and branch of the checkout are then never read).
191
+ const release = resolveReleaseSource({ cwd, env });
192
+ assertReleaseSource(release, { commandId: "right-release upload", env });
193
+ const split = isSnapshotSource(release);
194
+ const repoRoot = gitRootOf(release);
195
+ const commit = release.commit;
199
196
  const configPath = path.resolve(cwd, configName);
197
+ assertInsideSource(release, configPath, "the release config");
200
198
  if (!existsSync(configPath)) refuse("PLAY_CONFIG_INVALID", `release config is required: ${configName}`);
201
199
  // A legacy config is a module that runs on import (a JSON config is data): loaded only after the tree is proven clean.
202
200
  const config = await importReleaseConfig(configPath, { query: `upload=${Date.now()}` });
203
201
  const distribution = validatePlayDistribution(targetDistribution(config, "android"));
204
202
  const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, "android");
205
- const sealed = verifyPublicationRelease({ sealedDir, repoRoot, appRoot: path.dirname(configPath), commit, app: config.app, version: config.version, platform: "android", configPath, releaseId });
203
+ // The sealed release and its signing receipts are where the build wrote them (the checkout's `.right-release`).
204
+ const sealed = verifyPublicationRelease({ sealedDir, repoRoot, appRoot: split ? release.checkoutAppRoot : path.dirname(configPath), commit, app: config.app, version: config.version, platform: "android", configPath, releaseId },
205
+ split ? { verifyOwnership: (expected, root, scanRoot, scope) => verifyOwnershipEvidence(expected, root, scanRoot, scope, release) } : {});
206
+ if (split) assertBindsSource(release, sealed.manifest, "the sealed release");
206
207
  const entries = sealed.manifest.files.filter((entry) => entry.role === "aab");
207
208
  if (entries.length !== 1) refuse("PLAY_AAB_MISSING", "the sealed release must hold exactly one aab entry");
208
209
  const aab = sealed.artifacts.get(entries[0].name);
@@ -226,7 +227,7 @@ export async function runPlayUpload({
226
227
  fetchImpl = fetch, credentialStore, sign, nowSeconds, authorize = authorizePlayUpload,
227
228
  }) {
228
229
  refusePlayEnvSecrets(env);
229
- const auth = await authorize({ releaseId, configName, cwd });
230
+ const auth = await authorize({ releaseId, configName, cwd, env });
230
231
  const aabEvidence = { name: auth.entry.name, sha256: auth.entry.sha256, sizeBytes: auth.entry.sizeBytes };
231
232
  const plan = playUploadPlan({ distribution: auth.distribution, aab: aabEvidence });
232
233
  const stateRoot = path.join(auth.repoRoot, ".right-release", "state", releaseId, "android");
package/app-check.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  // The app check: step 1 of docs/PIPELINE-ARCHITECTURE.md for any repository that uses RightKit, shipped in @rightkit/release so an
2
- // app runs it without a RightKit checkout (`rightkit-gate check`, `right-release check`, the pre-push hook, and the start of every
2
+ // app runs it without a RightKit checkout (`rightkit-gate check`, `right-release check`, and the start of every
3
3
  // right-release command that builds or ships; see CHECKED_COMMANDS). RightKit's own scripts/preflight.mjs imports these rules and
4
4
  // adds the ones that police RightKit itself.
5
5
  //
6
- // It reads the committed files of one commit (or, for a compile, the tree on disk: `--worktree`), exported by git alone (ls-tree and
6
+ // It reads one commit or captured tree (or, for a compile, the tree on disk: `--worktree`), exported by git alone (ls-tree and
7
7
  // cat-file, no tar and no archive, so Windows depends on no tar on PATH), and reports every finding in one run, each with rule,
8
8
  // file, reason and fix. No network, no cargo, no pnpm, no compile, no broker, no environment, no repository code. A rule that throws
9
9
  // is a FAIL, never a pass. Nothing is written to the repository.
@@ -194,6 +194,7 @@ export function retireAliasText(text) {
194
194
  let count = 0;
195
195
  let out = text;
196
196
  const lines = new Set();
197
+ const matches = [];
197
198
  for (const [pattern, replacement, { unlessRetired = false, lineMust = null } = {}] of ALIAS_FORMS) {
198
199
  const hits = [];
199
200
  out = out.replace(pattern, (...m) => {
@@ -202,23 +203,54 @@ export function retireAliasText(text) {
202
203
  const line = lineAt(whole, offset);
203
204
  if ((unlessRetired && /retired/i.test(line)) || (lineMust && !lineMust.test(line))) return m[0];
204
205
  count += 1;
205
- hits.push(offset + m[0].search(/rightkit/));
206
+ // The line is counted in `whole`, the string this form is replacing in: earlier forms changed lengths, never line breaks,
207
+ // so a line number there is the line number in the original text, while an offset into `text` would drift.
208
+ const at = whole.slice(0, Math.max(0, offset + m[0].search(/rightkit/))).split("\n").length;
209
+ hits.push(at);
210
+ matches.push({ line: at, text: m[0] });
206
211
  return replacement.replace(/\$(\d)/g, (_, i) => m[Number(i)] ?? "");
207
212
  });
208
- // Offsets are positions in the text before this form's replacements; every replacement keeps its line count.
209
- for (const at of hits) lines.add(text.slice(0, Math.max(0, at)).split("\n").length);
213
+ for (const at of hits) lines.add(at);
210
214
  }
211
- return { text: out, count, lines: [...lines].sort((a, b) => a - b) };
215
+ return { text: out, count, lines: [...lines].sort((a, b) => a - b), matches };
216
+ }
217
+
218
+ /** Exact-line exceptions to P11: `{"exceptions": [{"file", "line", "reason"}]}`; an entry without a reason or that is malformed is a finding. */
219
+ export const RETIRED_ALIAS_EXCEPTIONS = ".rightkit/retired-alias-exceptions.json";
220
+ function readAliasExceptions(root, out) {
221
+ let raw;
222
+ try { raw = fs.readFileSync(path.join(root, RETIRED_ALIAS_EXCEPTIONS), "utf8"); } catch { return []; }
223
+ let list;
224
+ try { list = JSON.parse(raw).exceptions; } catch (error) { out.push({ problem: `${RETIRED_ALIAS_EXCEPTIONS} is not valid JSON (${error.message})`, fix: "fix the file" }); return []; }
225
+ if (!Array.isArray(list)) { out.push({ problem: `${RETIRED_ALIAS_EXCEPTIONS} has no "exceptions" array`, fix: "list {file, line, reason} entries" }); return []; }
226
+ return list.filter((e, i) => {
227
+ const ok = e && typeof e.file === "string" && typeof e.line === "string" && typeof e.match === "string" && e.match.length > 0 && e.line.includes(e.match) && typeof e.reason === "string" && e.reason.trim().length >= 10;
228
+ if (!ok) out.push({ problem: `${RETIRED_ALIAS_EXCEPTIONS} entry ${i} needs file, the exact line text, the exact matched text on it and a reason of 10+ characters`, fix: "complete or remove the entry" });
229
+ return ok;
230
+ });
212
231
  }
213
232
 
214
233
  function retiredAlias({ root, files }) {
215
234
  const out = [];
235
+ const aliasExceptions = readAliasExceptions(root, out);
216
236
  for (const file of files) {
217
- if (RETIRED_ALIAS_EXEMPT.test(file)) continue;
237
+ if (RETIRED_ALIAS_EXEMPT.test(file) || file === RETIRED_ALIAS_EXCEPTIONS) continue; // the exceptions list quotes the lines it spares
218
238
  let bytes;
219
239
  try { bytes = fs.readFileSync(path.join(root, file)); } catch { continue; }
220
240
  if (bytes.includes(0) || !bytes.includes("rightkit") || isGeneratedFile(bytes.toString("utf8"))) continue;
221
- const hits = retireAliasText(bytes.toString("utf8")).lines;
241
+ // A line that must keep the old name (a wire-stable string, a test of the retirement itself) is listed by its exact text in the
242
+ // checked repository's .rightkit/retired-alias-exceptions.json with a reason; any edit to that line makes it a finding again.
243
+ const textLines = bytes.toString("utf8").split("\n");
244
+ // An exception spares one match (`match`, the exact matched text) on one line (`line`, its exact full text) that occurs exactly
245
+ // once in the file: another match on the same line, a second copy of the line, or any edit stays a finding.
246
+ const lineOf = (n) => textLines[n - 1]?.replace(/\r$/, "");
247
+ const mine = aliasExceptions.filter((e) => e.file === file);
248
+ // One entry spares one occurrence: its line occurs once in the file and that line has exactly one match with that text.
249
+ const all = retireAliasText(bytes.toString("utf8")).matches;
250
+ const spared = (m) => mine.some((e) => e.line === lineOf(m.line) && e.match === m.text
251
+ && textLines.filter((l) => l.replace(/\r$/, "") === e.line).length === 1
252
+ && all.filter((o) => o.line === m.line && o.text === m.text).length === 1);
253
+ const hits = [...new Set(all.filter((m) => !spared(m)).map((m) => m.line))].sort((a, b) => a - b);
222
254
  if (hits.length) out.push({ problem: `${file}:${hits.slice(0, 8).join(",")}${hits.length > 8 ? ",…" : ""} invokes \`rightkit\`, the retired name of the RustRight client`, fix: "use rustright (node <rightkit>/scripts/retire-rightkit-alias.mjs --repo <this repository> rewrites them all)" });
223
255
  }
224
256
  return out;
@@ -1093,6 +1125,27 @@ export const RULES = Object.freeze([
1093
1125
  ...OWNERSHIP_CLASS_RULES,
1094
1126
  ]);
1095
1127
 
1128
+ // ---- tiers --------------------------------------------------------------------------------------------------------------------------
1129
+ //
1130
+ // Adrian, 2026-10-26: the apps lost every build route on 2026-10-25 and must build again through the one door before any new rule is
1131
+ // asked of them. The compile tier is the rule set every app was green on when it last built through the gate: the rules @rightkit/release
1132
+ // 0.6.6 published. Every rule added since (consumer, shell, Legion, ownership and source-closure contracts) is release-tier until the
1133
+ // synthesis waves make it true in every app, and is promoted here, in RightKit, never per app and never through a baseline.
1134
+ // compile gates compile admission: the RustRight broker, rightkit-gate check, --to compile|package, right-release build|dev|test.
1135
+ // release the whole set: --to qualify|release and every right-release command that qualifies, signs, ships or uploads.
1136
+ export const TIERS = Object.freeze(["compile", "release"]);
1137
+ export const COMPILE_TIER_IDS = Object.freeze(new Set([
1138
+ "eol-policy", "file-url", "retired-alias", "qa-platform", "test-harness", "scenario-skip", "release-config", "skip-checks",
1139
+ "qa-dev-server", "ffmpeg-release-escape", "switches-inventory", "speech-link", "e2e-under-cargo-test",
1140
+ ]));
1141
+ /** The tier a rule runs from: compile for the 0.6.6 set, release for everything added since. */
1142
+ export const tierOf = (rule) => (COMPILE_TIER_IDS.has(rule.id) ? "compile" : "release");
1143
+ /** The rules a tier runs: the compile tier is a subset, the release tier is every rule. Unknown tiers throw. */
1144
+ export function rulesForTier(tier, rules = RULES) {
1145
+ if (!TIERS.includes(tier)) throw new Error(`tier must be compile or release, not ${JSON.stringify(tier)}`);
1146
+ return tier === "release" ? rules : rules.filter((rule) => tierOf(rule) === "compile");
1147
+ }
1148
+
1096
1149
  // ---- export: the committed files, by git alone ----------------------------------------------------------------------------------
1097
1150
 
1098
1151
  /** The path a tree entry is written to. A tree is untrusted bytes: nothing may land outside `dest` or inside a `.git`. */
@@ -1178,10 +1231,16 @@ export function hasNoCommit(repo) {
1178
1231
  return !branch.error && branch.status === 0;
1179
1232
  }
1180
1233
 
1181
- /** The commit id `rev` names in `repo`. */
1234
+ /** The commit or tree id `rev` names in `repo`; commit refs keep their commit identity. */
1182
1235
  export function resolveCommit(repo, rev) {
1183
1236
  if (String(rev).startsWith("-")) throw new Error(`${JSON.stringify(rev)} is not a revision`);
1184
- return git(["rev-parse", "--verify", `${rev}^{commit}`], { cwd: repo }).trim();
1237
+ // Admission can name a dirty, captured tree with no commit. Peel tags without converting a commit to its tree: release
1238
+ // callers still check HEAD's commit, while the broker checks exactly its captured tree, never a live or HEAD fallback.
1239
+ const resolved = git(["rev-parse", "--verify", "--end-of-options", String(rev)], { cwd: repo }).trim();
1240
+ const object = git(["rev-parse", "--verify", `${resolved}^{}`], { cwd: repo }).trim();
1241
+ const type = git(["cat-file", "-t", object], { cwd: repo }).trim();
1242
+ if (type !== "commit" && type !== "tree") throw new Error(`${JSON.stringify(rev)} is not a commit or tree (got ${type})`);
1243
+ return object;
1185
1244
  }
1186
1245
 
1187
1246
  /** Run the selected rules over an exported tree. Returns { failures: [{ rule, gap, problem, fix, file?, subject?, identity? }], timings }. */
@@ -1301,7 +1360,7 @@ function loadBaseline({ explicit, root, central, repoKey, today }) {
1301
1360
  // ---- the check ---------------------------------------------------------------------------------------------------------------------
1302
1361
 
1303
1362
  /**
1304
- * Check one repository. `mode` is "commit" (the committed files of `commit`, default HEAD) or "worktree" (what a compile would
1363
+ * Check one repository. `mode` is "commit" (the commit or captured tree named by `commit`, default HEAD) or "worktree" (what a compile would
1305
1364
  * see). Returns { findings (failing), known (tolerated by the baseline), stale (identities), expired ([{ id, issue, added, sunset }]),
1306
1365
  * found (every finding), exitCode, lines, text (stdout), errLines, errText (stderr) }; nothing is printed.
1307
1366
  * `rules` is the rule list (this module's, or RightKit's preflight adds its own), `label` prefixes the summary, `central` is the
@@ -1309,12 +1368,18 @@ function loadBaseline({ explicit, root, central, repoKey, today }) {
1309
1368
  * `refuse` lists rules whose findings the baseline cannot excuse here, because the build refuses them whatever the baseline says
1310
1369
  * (BUILD_REFUSES); a listed one fails like a new one.
1311
1370
  */
1312
- export function runAppCheck({ repo, mode = "commit", commit, only = null, baseline, printBaseline = false, rules = RULES, label = "check", repoKey, central = rightkitCheckout(), now = new Date(), refuse = [] } = {}) {
1371
+ export function runAppCheck({ repo, mode = "commit", commit, only = null, baseline, printBaseline = false, rules = RULES, tier = null, label = "check", repoKey, central = rightkitCheckout(), now = new Date(), refuse = [] } = {}) {
1313
1372
  const lines = [];
1314
1373
  const errLines = [];
1374
+ if (tier !== null && !TIERS.includes(tier)) { errLines.push(`${label}: --rule-tier must be compile or release, not ${JSON.stringify(tier)}`); return { findings: [], known: [], stale: [], expired: [], found: [], exitCode: 2, lines, text: "", errLines, errText: errLines.join("\n") }; }
1315
1375
  const finish = (exitCode, extra = {}) => ({ findings: [], known: [], stale: [], expired: [], found: [], ...extra, exitCode, lines, text: lines.join("\n"), errLines, errText: errLines.join("\n") });
1316
1376
  const unknown = only?.filter((id) => !rules.some((r) => r.id === id)) ?? [];
1317
1377
  if (unknown.length) { errLines.push(`${label}: unknown rule(s): ${unknown.join(", ")} (see --list)`); return finish(2); }
1378
+ // --only is a view over the whole set. Otherwise the tier decides which rules run: the compile tier by default over the registry, or
1379
+ // the tier the caller named over any list; a caller-supplied rule list with no tier runs as given (tests, custom seams), labelled "selected".
1380
+ const tiered = !only && (tier !== null || rules === RULES);
1381
+ if (tiered) rules = rulesForTier(tier ?? "compile", rules);
1382
+ const tierLabel = tiered ? `${tier ?? "compile"} tier` : "selected";
1318
1383
  if (mode !== "commit" && mode !== "worktree") { errLines.push(`${label}: mode must be commit or worktree, not ${JSON.stringify(mode)}`); return finish(2); }
1319
1384
  if (mode === "worktree" && commit) { errLines.push(`${label}: --worktree and --commit are exclusive`); return finish(2); }
1320
1385
  repo = path.resolve(repo ?? process.cwd());
@@ -1346,10 +1411,12 @@ export function runAppCheck({ repo, mode = "commit", commit, only = null, baseli
1346
1411
  let listed;
1347
1412
  try { listed = loadBaseline({ explicit: baseline, root, central, repoKey: key, today }); }
1348
1413
  catch (error) { errLines.push(`${label}: cannot read the baseline ${error.file}: ${error.message}`); return finish(2); }
1349
- const ran = (id) => !only || only.includes(id.split("|")[0]);
1414
+ const ranIds = new Set(only ?? rules.map((r) => r.id));
1415
+ const ran = (id) => ranIds.has(id.split("|")[0]);
1350
1416
  const pastSunset = (entry) => entry.sunset !== undefined && entry.sunset < today;
1351
1417
  const expired = listed.entries.filter((entry) => pastSunset(entry) && ran(entry.id));
1352
- const applied = applyBaseline(found, listed.entries.filter((entry) => !pastSunset(entry)).map((entry) => entry.id), only);
1418
+ // A baseline entry of a rule this tier did not run is neither known nor stale here: the release tier judges it.
1419
+ const applied = applyBaseline(found, listed.entries.filter((entry) => !pastSunset(entry)).map((entry) => entry.id), [...ranIds]);
1353
1420
  const refused = applied.known.filter((f) => refuse.includes(f.rule)).map((f) => ({ ...f, problem: `${f.problem} (the baseline lists it, but the build refuses it)` }));
1354
1421
  const lapsed = new Map(expired.map((entry) => [entry.id, entry]));
1355
1422
  const fresh = [...applied.fresh.map((f) => (f.identity && lapsed.has(f.identity) ? { ...f, problem: `${f.problem} (its baseline entry expired: sunset ${lapsed.get(f.identity).sunset}; ${APP_BASELINE_EXPIRY_RULE})` } : f)), ...refused];
@@ -1367,10 +1434,10 @@ export function runAppCheck({ repo, mode = "commit", commit, only = null, baseli
1367
1434
  const problems = fresh.length + stale.length + expired.length;
1368
1435
  const result = { findings: fresh, known, stale, expired, found };
1369
1436
  if (problems) {
1370
- lines.push(`${label}: ${problems} problem(s) in ${checked.slice(0, 12)} (${ruleCount} rules, ${seconds}s${known.length ? `, ${known.length} known` : ""}). Each would refuse later in CI or publication; fix and commit, then push.`);
1437
+ lines.push(`${label}: ${problems} problem(s) in ${checked.slice(0, 12)} (${ruleCount} rules, ${tierLabel}, ${seconds}s${known.length ? `, ${known.length} known` : ""}). Each would refuse later in CI or publication; fix and commit, then push.`);
1371
1438
  return finish(1, result);
1372
1439
  }
1373
- lines.push(`${label}: ${checked.slice(0, 12)} passed ${ruleCount} rules in ${seconds}s${known.length ? ` (${known.length} known finding(s) in ${listed.listedFile}; fix them, then remove their entries)` : ""}`);
1440
+ lines.push(`${label}: ${checked.slice(0, 12)} passed ${ruleCount} rules (${tierLabel}) in ${seconds}s${known.length ? ` (${known.length} known finding(s) in ${listed.listedFile}; fix them, then remove their entries)` : ""}`);
1374
1441
  return finish(0, result);
1375
1442
  } finally {
1376
1443
  fs.rmSync(root, { recursive: true, force: true });
@@ -1394,7 +1461,7 @@ export const CHECKED_COMMANDS = new Map([
1394
1461
  ["release", "commit"], ["build", "commit"], ["package", "commit"], ["dev", "worktree"], ["qa", "commit"], ["upload", "commit"],
1395
1462
  ["publish", "commit"], ["publish-update", "commit"], ["github", "commit"], ["doctor", "worktree"],
1396
1463
  ["sign-windows", "commit"], ["sign-updater", "commit"], ["create-mac-updater", "commit"],
1397
- ["test", "worktree"], ["web-build", "commit"], ["swift-package", "commit"], ["recipe-build", "commit"], ["stage-sidecar", "worktree"], ["sign-candidate", "commit"],
1464
+ ["test", "worktree"], ["web-build", "commit"], ["swift-package", "commit"], ["recipe-build", "commit"], ["assemble-app", "commit"], ["stage-sidecar", "worktree"], ["sign-candidate", "commit"],
1398
1465
  ["addon build", "commit"], ["sign-dev-artifact", "worktree"],
1399
1466
  ]);
1400
1467
 
@@ -1411,6 +1478,11 @@ export function checkedCommandOf(args) {
1411
1478
  * minutes into the build. */
1412
1479
  export const BUILD_REFUSES = Object.freeze(["switches-inventory"]);
1413
1480
 
1481
+ /** The checked commands that qualify, sign, ship or upload: they owe the release tier (every rule). The rest owe the compile tier. */
1482
+ export const RELEASE_TIER_COMMANDS = Object.freeze(new Set(["release", "upload", "publish", "publish-update", "github", "sign-windows", "sign-updater", "create-mac-updater", "sign-candidate"]));
1483
+ /** `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. */
1484
+ export const tierForCommand = (name, args = []) => (name === "qa" ? (args.includes("installed") ? "release" : "compile") : RELEASE_TIER_COMMANDS.has(name) ? "release" : "compile");
1485
+
1414
1486
  /** Every other right-release command, each with why the check does not guard it. A command that is in neither map fails the
1415
1487
  * classification test in app-check.test.mjs, so a new command cannot appear unclassified. */
1416
1488
  export const UNCHECKED_COMMANDS = new Map([
@@ -1441,7 +1513,7 @@ export const UNCHECKED_COMMANDS = new Map([
1441
1513
  ["identity-scan", "asserts the signature of a path named on the command line"],
1442
1514
  ["apple-profile-create", "provisioning-profile credentials; no app source"],
1443
1515
  ["ios-credentials", "stores or checks App Store Connect credentials; no app source"],
1444
- ["hooks", "installs the git hooks, and the pre-push hook runs the check"],
1516
+ ["hooks", "retires the git hooks an earlier release wrote (install-hook); writes none"],
1445
1517
  ["suite-doctor", "verifies the Right Suite contract tests; a report"],
1446
1518
  ["cache", "host build-cache maintenance"],
1447
1519
  ["disk", "host disk maintenance"],
@@ -1506,14 +1578,18 @@ export const appDirectory = (args, cwd) => {
1506
1578
  * check is green (nothing is printed then), else { exitCode: 1, lines } for the caller to print: the findings and why nothing
1507
1579
  * was started. A check that cannot run is red too.
1508
1580
  */
1509
- export function guardCommand({ args, cwd = process.cwd(), run = runAppCheck } = {}) {
1510
- const mode = checkModeFor(args);
1511
- if (!mode) return null;
1581
+ export function guardCommand({ args, cwd = process.cwd(), run = runAppCheck, source = null } = {}) {
1582
+ const checkMode = checkModeFor(args);
1583
+ if (!checkMode) return null;
1512
1584
  const name = args[0].startsWith("-") ? "build" : checkedCommandOf(args) ?? args[0];
1513
- const dir = appDirectory(args, cwd);
1514
- const repo = repoRoot(dir);
1585
+ // A source split (source-split.mjs): the process runs in the door's snapshot of `source.commit`, which has no git directory. The
1586
+ // rules read that commit from the checkout `source.repo`, whatever the command's usual mode (the snapshot IS the commit, so a
1587
+ // "worktree" command checks it as a commit; the checkout's own working tree is never what is built).
1588
+ const mode = source ? "commit" : checkMode;
1589
+ const dir = source ? source.repo : appDirectory(args, cwd);
1590
+ const repo = source ? source.repo : repoRoot(dir);
1515
1591
  if (!repo) return { exitCode: 1, lines: [`right-release ${name}: the app check needs a git repository and ${dir} is not inside one; nothing was started`] };
1516
- const result = run({ repo, mode, label: "check", refuse: BUILD_REFUSES });
1592
+ const result = run({ repo, mode, ...(source ? { commit: source.commit } : {}), tier: tierForCommand(name, args), label: "check", refuse: BUILD_REFUSES });
1517
1593
  if (result.exitCode === 0) return null;
1518
1594
  const why = result.exitCode === 2 ? "could not run" : "found problems";
1519
1595
  return { exitCode: 1, lines: [...result.lines, ...result.errLines, `right-release ${name}: the app check ${why} in ${repo}; nothing was started. Fix the above and commit, then run it again.`] };
@@ -1522,12 +1598,14 @@ export function guardCommand({ args, cwd = process.cwd(), run = runAppCheck } =
1522
1598
  // ---- command line ---------------------------------------------------------------------------------------------------------------
1523
1599
 
1524
1600
  /**
1525
- * `check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]`.
1601
+ * `check [--repo <dir>] [--commit <rev> | --worktree] [--rule-tier compile|release] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]`.
1602
+ * `--rule-tier` defaults to compile (see TIERS); `--list` prints every rule with its tier. (`--tier patch|update` is the upload tier, elsewhere.)
1526
1603
  * `--repo` defaults to the git top-level of `cwd` (or `defaultRepo`). Returns the exit code: 0 pass, 1 findings, 2 could not run.
1527
1604
  */
1528
1605
  export async function main(argv, { out = (s) => process.stdout.write(`${s}\n`), err = (s) => process.stderr.write(`${s}\n`), label = "check", rules = RULES, defaultRepo = null, cwd = process.cwd(), central, now } = {}) {
1529
1606
  const arg = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; };
1530
- if (argv.includes("--list")) { for (const r of rules) out(`${r.id} (${r.gap})`); return 0; }
1607
+ if (argv.includes("--list")) { for (const r of rules) out(`${r.id} (${r.gap}) ${tierOf(r)}`); return 0; }
1608
+ const tier = arg("--rule-tier") ?? "compile";
1531
1609
  const repo = arg("--repo") ? path.resolve(arg("--repo")) : defaultRepo ?? repoRoot(cwd);
1532
1610
  if (!repo) { err(`${label}: not inside a git repository (use --repo <dir>)`); return 2; }
1533
1611
  const result = runAppCheck({
@@ -1537,7 +1615,7 @@ export async function main(argv, { out = (s) => process.stdout.write(`${s}\n`),
1537
1615
  only: arg("--only")?.split(",").filter(Boolean) ?? null,
1538
1616
  baseline: arg("--baseline"),
1539
1617
  printBaseline: argv.includes("--print-baseline"),
1540
- rules, label, ...(central === undefined ? {} : { central }), ...(now ? { now } : {}),
1618
+ rules, tier, label, ...(central === undefined ? {} : { central }), ...(now ? { now } : {}),
1541
1619
  });
1542
1620
  for (const line of result.lines) out(line);
1543
1621
  for (const line of result.errLines) err(line);