@rightkit/release 0.6.8 → 0.6.10

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 (52) hide show
  1. package/README.md +25 -9
  2. package/admission-files.mjs +1 -1
  3. package/app-check.mjs +94 -20
  4. package/assemble-app-command.mjs +151 -0
  5. package/assembled-app.mjs +445 -0
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +6 -0
  8. package/bin/rightkit-gate.mjs +928 -82
  9. package/build-launch.mjs +88 -0
  10. package/build-ownership.mjs +1231 -0
  11. package/build-release.mjs +2 -1
  12. package/cargo-contract.mjs +3 -1
  13. package/cargo-target-shim.mjs +166 -0
  14. package/class-a-launch-guard.mjs +126 -0
  15. package/cli/right-release.mjs +3 -1
  16. package/codesign-shim.mjs +11 -1
  17. package/deterministic-scans.mjs +807 -56
  18. package/development-signing.mjs +4 -1
  19. package/development.mjs +110 -20
  20. package/door-policy.mjs +61 -6
  21. package/gate-executor.mjs +14 -2
  22. package/gate-stages.mjs +15 -5
  23. package/heavy-command.mjs +3 -2
  24. package/install-app.mjs +11 -2
  25. package/install-assembled-app.mjs +423 -0
  26. package/ios-dev.mjs +70 -3
  27. package/linux-artifacts.mjs +93 -0
  28. package/local-publish.mjs +161 -26
  29. package/managed-cargo.mjs +47 -5
  30. package/managed-root-guard.mjs +34 -0
  31. package/native-helpers.mjs +355 -0
  32. package/package-recipe-targets.mjs +52 -7
  33. package/package.json +4 -3
  34. package/pipeline-receipts.mjs +302 -302
  35. package/project-type.mjs +2 -2
  36. package/provenance-verify.mjs +115 -0
  37. package/publish-cargo.mjs +8 -6
  38. package/qa-admission-policy.json +10 -1
  39. package/recipe-build.mjs +73 -5
  40. package/release-config.mjs +1 -1
  41. package/release.mjs +15 -5
  42. package/rightkit-versions.json +13 -7
  43. package/snapshot-check.mjs +306 -0
  44. package/snapshot-git.mjs +166 -0
  45. package/snapshot-request.mjs +161 -0
  46. package/snapshot-root-config.mjs +76 -0
  47. package/snapshot.mjs +856 -0
  48. package/stage-commands.mjs +60 -0
  49. package/stage-sidecar.mjs +57 -7
  50. package/test-command.mjs +17 -4
  51. package/unit-gate.mjs +259 -0
  52. package/web-build.mjs +38 -6
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
+ ## Every build tool is a door
205
+
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, the pre-push hook, `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
+
204
221
  ## The pre-push gate on a host without the broker: the Rust gate is deferred, the HOST RECEIPT gates the merge
205
222
 
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.
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.
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
 
@@ -391,9 +408,7 @@ Hardening, applied in both modes (a failure is a warning in `warn` and a refusal
391
408
 
392
409
  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
410
 
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.
411
+ ## Gate consumer rows
397
412
 
398
413
  `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
414
 
@@ -407,7 +422,7 @@ right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id d
407
422
 
408
423
  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
424
 
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}`).
425
+ 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
426
 
412
427
  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
428
 
@@ -574,7 +589,7 @@ Supported: `title`, `icon` (`.icns` volume icon), `background` (PNG or TIFF), `i
574
589
 
575
590
  ## One gate for GitHub uploads
576
591
 
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).
592
+ `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
593
 
579
594
  ## GitHub Releases updater lane (`publish-update`, `github-releases`)
580
595
 
@@ -686,6 +701,7 @@ Any app that ships more than the ffmpeg sidecar (MailRight: `mailright-engine`,
686
701
  - **`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
702
  - **`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
703
  - **`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`.
704
+ - **`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
705
  - **`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
706
  - **`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
707
  - **`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
  },
package/app-check.mjs CHANGED
@@ -3,7 +3,7 @@
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, the pre-push hook, 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([
@@ -1513,7 +1585,7 @@ export function guardCommand({ args, cwd = process.cwd(), run = runAppCheck } =
1513
1585
  const dir = appDirectory(args, cwd);
1514
1586
  const repo = repoRoot(dir);
1515
1587
  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 });
1588
+ const result = run({ repo, mode, tier: tierForCommand(name, args), label: "check", refuse: BUILD_REFUSES });
1517
1589
  if (result.exitCode === 0) return null;
1518
1590
  const why = result.exitCode === 2 ? "could not run" : "found problems";
1519
1591
  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 +1594,14 @@ export function guardCommand({ args, cwd = process.cwd(), run = runAppCheck } =
1522
1594
  // ---- command line ---------------------------------------------------------------------------------------------------------------
1523
1595
 
1524
1596
  /**
1525
- * `check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]`.
1597
+ * `check [--repo <dir>] [--commit <rev> | --worktree] [--rule-tier compile|release] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]`.
1598
+ * `--rule-tier` defaults to compile (see TIERS); `--list` prints every rule with its tier. (`--tier patch|update` is the upload tier, elsewhere.)
1526
1599
  * `--repo` defaults to the git top-level of `cwd` (or `defaultRepo`). Returns the exit code: 0 pass, 1 findings, 2 could not run.
1527
1600
  */
1528
1601
  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
1602
  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; }
1603
+ if (argv.includes("--list")) { for (const r of rules) out(`${r.id} (${r.gap}) ${tierOf(r)}`); return 0; }
1604
+ const tier = arg("--rule-tier") ?? "compile";
1531
1605
  const repo = arg("--repo") ? path.resolve(arg("--repo")) : defaultRepo ?? repoRoot(cwd);
1532
1606
  if (!repo) { err(`${label}: not inside a git repository (use --repo <dir>)`); return 2; }
1533
1607
  const result = runAppCheck({
@@ -1537,7 +1611,7 @@ export async function main(argv, { out = (s) => process.stdout.write(`${s}\n`),
1537
1611
  only: arg("--only")?.split(",").filter(Boolean) ?? null,
1538
1612
  baseline: arg("--baseline"),
1539
1613
  printBaseline: argv.includes("--print-baseline"),
1540
- rules, label, ...(central === undefined ? {} : { central }), ...(now ? { now } : {}),
1614
+ rules, tier, label, ...(central === undefined ? {} : { central }), ...(now ? { now } : {}),
1541
1615
  });
1542
1616
  for (const line of result.lines) out(line);
1543
1617
  for (const line of result.errLines) err(line);
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ // `right-release assemble-app`: the production (and dev-artifact) face of the declarative app assembly (`assembly.mac`,
3
+ // `assembly.win`; assembled-app.mjs). The same definition drives `right-release dev` (install-assembled-app.mjs).
4
+ //
5
+ // assemble-app --platform mac|win --profile release [--build] [--out <dir>] [--config <file>]
6
+ // lays out the unsigned bundle/payload at assembly.<p>.stagingOutput (the existing sign matrix, notarization and DMG/NSIS
7
+ // steps read it), writes the release binding (receipt hashes) beside it and verifies both. --build first runs
8
+ // assembly.<p>.prepare.release and (mac) xcodegen + xcodebuild of the outer app. --profile dev needs --out.
9
+ // assemble-app --platform mac|win --verify-staged [--signed]
10
+ // read-only structural proof of the staged output (required files, launchd BundleProgram, bundle id, no dev marker,
11
+ // the release binding; --signed also runs `codesign --verify --strict --deep`, and skips the byte re-hash).
12
+ // assemble-app --platform win --package
13
+ // makensis from assembly.win.nsis over the staged payload, then Authenticode-signs the installer (sign-windows).
14
+ import { spawnSync } from "./background-process.mjs";
15
+ import nodeFs, { realpathSync } from "node:fs";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { assembleMacBundle, assembleWindowsPayload, AssembledAppError, loadStageReceipt, resolveAssembly, resolveNestedApp, verifyMacBundle, verifyReleaseBinding, verifyWindowsPayload, writeReleaseBinding } from "./assembled-app.mjs";
19
+ import { buildOuterApp, outerProductPath, resolveAssemblyStorage } from "./install-assembled-app.mjs";
20
+
21
+ const SIGN_WINDOWS = fileURLToPath(new URL("./sign-windows.mjs", import.meta.url));
22
+ const refuse = (code, message) => { throw new AssembledAppError(code, message); };
23
+
24
+ export function parseAssembleArgs(argv) {
25
+ const opts = { platform: undefined, profile: "release", build: false, verifyStaged: false, signed: false, package: false, config: undefined, out: undefined };
26
+ for (let i = 0; i < argv.length; i += 1) {
27
+ const arg = argv[i];
28
+ const value = () => { const next = argv[i + 1]; if (!next || next.startsWith("--")) throw new Error(`${arg} needs a value`); i += 1; return next; };
29
+ if (arg === "--platform") opts.platform = value();
30
+ else if (arg === "--profile") opts.profile = value();
31
+ else if (arg === "--config") opts.config = value();
32
+ else if (arg === "--out") opts.out = value();
33
+ else if (arg === "--build") opts.build = true;
34
+ else if (arg === "--verify-staged") opts.verifyStaged = true;
35
+ else if (arg === "--signed") opts.signed = true;
36
+ else if (arg === "--package") opts.package = true;
37
+ else throw new Error(`unknown assemble-app argument: ${arg}`);
38
+ }
39
+ if (!["mac", "win"].includes(opts.platform)) throw new Error("assemble-app requires --platform mac|win");
40
+ if (!["dev", "release"].includes(opts.profile)) throw new Error("--profile must be dev or release");
41
+ if (opts.signed && !opts.verifyStaged) throw new Error("--signed only applies to --verify-staged");
42
+ if (opts.package && opts.platform !== "win") throw new Error("--package applies to --platform win (the mac DMG is packaged by the existing dmg step)");
43
+ if (Number(opts.verifyStaged) + Number(opts.package) > 1) throw new Error("--verify-staged and --package are separate steps");
44
+ if (opts.profile === "dev" && !opts.out && !opts.verifyStaged && !opts.package) throw new Error("--profile dev needs --out <dir>: a dev payload never lands in the release staging folder");
45
+ return opts;
46
+ }
47
+
48
+ function runPrepare({ root, spec, env, run, resolveCommand, log }) {
49
+ for (const command of spec.prepare.release) {
50
+ const launch = resolveCommand(command.cmd, command.args, { root, env });
51
+ const cwd = path.resolve(root, command.cwd ?? ".");
52
+ log(`assemble-app: prepare ${command.cmd} ${command.args.join(" ")}`);
53
+ const result = run(launch.command, launch.args, { cwd, env: { ...(launch.env ?? env), ...(command.env ?? {}) }, stdio: "inherit", windowsHide: true, shell: false, timeout: command.timeoutMs });
54
+ if (result.error || result.status !== 0) refuse("ASSEMBLY_BUILD_FAILED", `prepare command failed (${result.status ?? result.error?.message}): ${command.cmd} ${command.args.join(" ")}`);
55
+ }
56
+ }
57
+
58
+ /** Where the output goes: --out (relative to the app root) or the declared staging folder. */
59
+ const outputOf = ({ root, spec, opts }) => path.resolve(root, opts.out ?? spec.stagingOutput);
60
+
61
+ export function findMakensis({ env = process.env, fs = nodeFs, run = spawnSync } = {}) {
62
+ const local = env.LOCALAPPDATA ?? "";
63
+ const candidates = [env.RIGHTKIT_MAKENSIS, env.MAKENSIS, "C:\\Program Files (x86)\\NSIS\\makensis.exe", "C:\\Program Files\\NSIS\\makensis.exe", local && path.join(local, "tauri", "NSIS", "makensis.exe")];
64
+ const found = candidates.find((candidate) => candidate && fs.existsSync(candidate));
65
+ if (found) return found;
66
+ const where = run("where", ["makensis"], { encoding: "utf8", windowsHide: true, shell: false });
67
+ if (where.status === 0 && String(where.stdout ?? "").trim()) return String(where.stdout).split(/\r?\n/)[0].trim();
68
+ return refuse("ASSEMBLY_TOOL_MISSING", "makensis (NSIS 3) was not found; install NSIS on the packaging host or set RIGHTKIT_MAKENSIS");
69
+ }
70
+
71
+ /** The makensis argv for assembly.win.nsis: `{payload}`, `{output}` and `{version}` are substituted in every define. */
72
+ export function nsisArguments({ root, spec, payload, output, version }) {
73
+ const subst = (text) => text.replaceAll("{payload}", payload).replaceAll("{output}", output).replaceAll("{version}", version);
74
+ return ["/V2", ...Object.entries(spec.nsis.defines).map(([name, value]) => `/D${name}=${subst(value)}`), path.resolve(root, spec.nsis.script)];
75
+ }
76
+
77
+ /** Run one assemble-app invocation. `deps` replaces the process and file-system effects (tests). */
78
+ export async function assembleApp({ root, config, opts, env = process.env, deps = {} }) {
79
+ const d = { fs: nodeFs, run: spawnSync, log: (line) => console.log(line), storage: resolveAssemblyStorage, buildOuter: buildOuterApp, ...deps };
80
+ if (!d.resolveCommand) ({ resolveCommand: d.resolveCommand } = await import("./development.mjs"));
81
+ const spec = resolveAssembly(config, opts.platform);
82
+ const out = outputOf({ root, spec, opts });
83
+ const profile = opts.profile;
84
+
85
+ if (opts.verifyStaged) {
86
+ if (spec.platform === "mac") {
87
+ if (profile === "release") verifyReleaseBinding({ root, spec, output: out, rehash: !opts.signed, fs: d.fs });
88
+ verifyMacBundle({ root, spec, bundle: out, profile, signed: opts.signed, env, deps: { fs: d.fs, run: d.run } });
89
+ } else {
90
+ if (profile === "release") verifyReleaseBinding({ root, spec, output: out, rehash: false, fs: d.fs });
91
+ verifyWindowsPayload({ spec, payload: out, profile, fs: d.fs });
92
+ }
93
+ d.log(`assemble-app: ${out} verified`);
94
+ return { verified: out };
95
+ }
96
+
97
+ if (opts.package) {
98
+ if (!spec.nsis) refuse("ASSEMBLY_CONFIG_INVALID", "assembly.win.nsis is not declared");
99
+ verifyReleaseBinding({ root, spec, output: out, rehash: false, fs: d.fs });
100
+ verifyWindowsPayload({ spec, payload: out, profile: "release", fs: d.fs });
101
+ const output = path.resolve(root, spec.nsis.output);
102
+ d.fs.mkdirSync(path.dirname(output), { recursive: true });
103
+ d.fs.rmSync(output, { force: true });
104
+ const makensis = (d.findMakensis ?? findMakensis)({ env, fs: d.fs, run: d.run });
105
+ const made = d.run(makensis, nsisArguments({ root, spec, payload: out, output, version: String(config.version ?? "") }), { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
106
+ if (made.error || made.status !== 0) refuse("ASSEMBLY_BUILD_FAILED", `makensis failed (${made.status ?? made.error?.message})`);
107
+ if (!d.fs.existsSync(output)) refuse("ASSEMBLY_BUILD_FAILED", `makensis did not write ${output}`);
108
+ // The payload exes were signed by the release sign step; the installer is signed here with RightKit's own signer.
109
+ const signed = d.run(process.execPath, [SIGN_WINDOWS, output], { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
110
+ if (signed.error || signed.status !== 0) refuse("ASSEMBLY_SIGNING_FAILED", `sign-windows failed on ${output} (${signed.status ?? signed.error?.message})`);
111
+ d.log(`assemble-app: installer ${output}`);
112
+ return { installer: output };
113
+ }
114
+
115
+ if (opts.build) runPrepare({ root, spec, env, run: d.run, resolveCommand: d.resolveCommand, log: d.log });
116
+ const receipt = loadStageReceipt({ root, spec, profile, fs: d.fs });
117
+ if (spec.platform === "mac") {
118
+ const storage = d.storage({ root, profile, env, run: d.run });
119
+ if (opts.build) d.buildOuter({ root, spec, storage, profile, env, run: d.run });
120
+ const outerApp = outerProductPath({ spec, storage });
121
+ const nestedApps = spec.nested.map((entry) => resolveNestedApp({ root, entry, spec, profile, deps: { fs: d.fs, ...(d.resolveTargetRoot ? { resolveTargetRoot: d.resolveTargetRoot } : {}) } }));
122
+ const { bound } = assembleMacBundle({ root, spec, profile, out, outerApp, nestedApps, receipt, env, deps: { fs: d.fs, run: d.run, log: d.log, ...(d.commitCount ? { commitCount: d.commitCount } : {}) } });
123
+ if (profile === "release") {
124
+ writeReleaseBinding({ root, spec, bound, fs: d.fs });
125
+ verifyReleaseBinding({ root, spec, output: out, rehash: true, fs: d.fs });
126
+ }
127
+ verifyMacBundle({ root, spec, bundle: out, profile, signed: false, env, deps: { fs: d.fs, run: d.run } });
128
+ } else {
129
+ const { bound } = assembleWindowsPayload({ root, spec, profile, out, receipt, deps: { fs: d.fs, log: d.log } });
130
+ if (profile === "release") {
131
+ writeReleaseBinding({ root, spec, bound, fs: d.fs });
132
+ verifyReleaseBinding({ root, spec, output: out, rehash: true, fs: d.fs });
133
+ }
134
+ verifyWindowsPayload({ spec, payload: out, profile, fs: d.fs });
135
+ }
136
+ d.log(`assemble-app: ${profile} ${spec.platform} output ${out}`);
137
+ return { output: out };
138
+ }
139
+
140
+ export async function main(argv = process.argv.slice(2)) {
141
+ const opts = parseAssembleArgs(argv);
142
+ const { defaultReleaseConfigFile, importReleaseConfig } = await import("./release-config.mjs");
143
+ const configPath = path.resolve(opts.config ?? defaultReleaseConfigFile());
144
+ const config = await importReleaseConfig(configPath, { lenient: true });
145
+ return assembleApp({ root: path.dirname(configPath), config, opts, env: process.env });
146
+ }
147
+
148
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
149
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
150
+ main().catch((error) => { console.error(`right-release assemble-app: ${error.code ? `[${error.code}] ` : ""}${error.message}`); process.exitCode = 1; });
151
+ }