@rightkit/release 0.6.3 → 0.6.5

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 (48) hide show
  1. package/README.md +67 -5
  2. package/admission-files.mjs +279 -0
  3. package/admission-host.mjs +403 -0
  4. package/admission.mjs +146 -0
  5. package/app-check.mjs +869 -0
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +68 -29
  8. package/build-release.mjs +81 -21
  9. package/cli/right-release.mjs +39 -5
  10. package/deterministic-scans.mjs +42 -15
  11. package/development.mjs +3 -2
  12. package/hosted-workflow-policy.mjs +111 -0
  13. package/inventory-check.mjs +12 -1
  14. package/ios-dev.mjs +2 -2
  15. package/ios-host.mjs +7 -7
  16. package/ios-package-recipe.mjs +2 -2
  17. package/local-publish.mjs +1 -1
  18. package/local-qualify.mjs +3 -3
  19. package/local-runtime.mjs +1 -1
  20. package/ownership-dependency-allowlist.json +2 -0
  21. package/package.json +5 -3
  22. package/pipeline-admission.mjs +5 -4
  23. package/pipeline-receipts.mjs +302 -302
  24. package/pipeline-scan.mjs +3 -3
  25. package/pipeline-tools.mjs +175 -171
  26. package/prebuild-gates.mjs +191 -0
  27. package/progress-control.mjs +1 -1
  28. package/publish-update.mjs +4 -3
  29. package/qa-admission-policy.json +5 -0
  30. package/release-config.mjs +353 -330
  31. package/release-qa-audit.mjs +37 -11
  32. package/release-qa-command.mjs +46 -5
  33. package/release-qa-fixtures.mjs +127 -0
  34. package/release-qa-floor.json +6 -1
  35. package/release-qa-hardening.mjs +173 -0
  36. package/release-qa-policy.mjs +168 -0
  37. package/release-qa-receipt.mjs +21 -1
  38. package/release-switch-scan.mjs +137 -6
  39. package/release.mjs +333 -231
  40. package/rightapps-register.mjs +3 -1
  41. package/rightkit-drift.mjs +1 -1
  42. package/rightkit-ownership.json +15 -5
  43. package/rightkit-versions.json +50 -37
  44. package/stage-sidecar.mjs +17 -15
  45. package/suite-api-literal.mjs +109 -0
  46. package/suite-api.generated.mjs +78 -0
  47. package/swap-helper-bundle.mjs +2 -2
  48. package/win-package-recipe.mjs +7 -4
package/README.md CHANGED
@@ -59,6 +59,18 @@ Every Right app has absolutely NO access to DevTools; the single exception is Vi
59
59
  - S1-S4 need `compiledOut`: `feature:<name>`, `cfg:test`, `cfg:debug_assertions` or, for Swift, `swift:DEBUG` (the read sits inside `#if DEBUG`; the read exists only there). The source walk skips test directories, including Swift bundles named `Tests`, `UITests`, `<App>Tests` and `<App>UITests`. S5 needs `userGesture: true`. S6 needs `valueType` of `bool`, `int` or `enum`.
60
60
  - `anchor` and `note` are informational. Fixtures: `fixtures/release-switches/` (`app/` is a clean example).
61
61
 
62
+ #### Non-shipped tooling: `releaseSwitches.nonShipped`
63
+
64
+ On desktop (non-iOS) targets the source walk covers the whole app root, so app tooling that never ships (a bakeoff harness) can produce `unregistered-read` findings that cannot truthfully be declared in `switches.json`. Declare such directories in the release config:
65
+
66
+ ```json
67
+ "releaseSwitches": { "nonShipped": [ { "dir": "bakeoff", "reason": "bakeoff harness; not compiled or bundled" } ] }
68
+ ```
69
+
70
+ - It is an exclusion list: any directory not listed is still scanned, so an omission fails safe. `dir` is relative to the app root; `reason` is required and non-empty. iOS is unchanged (it already walks only the Xcode project and the local crates in its cargo tree).
71
+ - Every entry is validated before the walk and a bad one fails the release with `inventory-invalid`. Refused: a dir that is missing, not a real directory (a symlink), absolute, escaping the root, or the root itself; an empty `reason`; a dir that contains or sits inside a local crate of the resolved release cargo tree (a crate at the app root protects `src/`), `src-tauri`, or the Tauri `frontendDist` bundle; a dir that contains a `package.json` or sits inside a directory below the root that has one; when the root has a `package.json`, `src/`, `public/` and every existing directory named by the root `index.html` (`src`/`href`), a quoted string in `vite.config.*` (root, publicDir, build input) or `include`/`files` of `tsconfig.json`, plus anything inside or containing them; and any dir holding a path named by `src-tauri/tauri.conf.json` `beforeBuildCommand`/`beforeDevCommand`/`beforeBundleCommand` a root `package.json` `build*` script, or the release recipe's own commands (`targets.<os>.prePackage`, `package`, `postSign`, `packageHook`, the finalizer; followed through `pnpm|npm|yarn run <script>`), because those run during the release build.
72
+ - Not protected: the release code does not otherwise know the frontend source layout. A frontend input that none of the rules above names (for example one referenced only from a nested config, or built by a script not reachable from those commands) is not refused, so list only directories you know never ship.
73
+
62
74
  ## Commands
63
75
 
64
76
  `right-release` maps these subcommands to the modules in this package:
@@ -79,6 +91,36 @@ Every Right app has absolutely NO access to DevTools; the single exception is Vi
79
91
  | Legal | `license-notices npm`, `license-notices cargo`, `license-notices binaries` |
80
92
  | Other | `dev`, `doctor`, `suite-doctor`, `qa`, `dmg`, `cache`, `apple`, `inventory`, `model`, `addon`, `hooks`, `lsclean`, `generate-dmg-background`, `publish cargo`, `publish npm`, `publish swift` |
81
93
 
94
+ ## The app check: deterministic first, no flag skips it
95
+
96
+ `right-release check [--repo <dir>] [--config <file>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]`
97
+ is the offline app check, the same as `rightkit-gate check`: it reads files only (the committed tree of `--commit`, default HEAD, or with
98
+ `--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,
99
+ 1 has findings, 2 could not run (a repository with no commit yet says `the repository has no commit yet; commit first`). The repository
100
+ is the git top-level of `--config`'s folder, else of `--app-root` (`recipe-build`), else of the working directory.
101
+
102
+ The commands that compile, package, sign, ship or inspect a release run it first, in the same process, and start nothing when it is
103
+ red or cannot run (outside a git repository it is red). There is no flag that skips it: `--help`, `--dry-run`, `--check` and `--list`
104
+ only change what a command does after the check.
105
+
106
+ | Mode | Commands |
107
+ |---|---|
108
+ | `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` |
109
+ | `worktree` (the tree on disk, as a compile sees it) | `dev`, `doctor`, `test`, `stage-sidecar`, `sign-dev-artifact` |
110
+
111
+ Not guarded: the report forms of guarded commands (`publish cargo|npm|swift`, `qa audit`, `doctor --all`) and every other command.
112
+ 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
113
+ neither list. `dmg` takes `--app`/`--out` paths, compiles nothing and has no repository; `addon` is checked only as `addon build` (it
114
+ runs the add-on's own build command), its `doctor`, `upload` and `adopt` forms compile nothing.
115
+
116
+ Known findings are tolerated only through a baseline: `--baseline <file>`, else the checked tree's `.rightkit/app-check-baseline.json`,
117
+ else (from a RightKit checkout only) `ci/app-check-baseline.json`. The app's own file is always `{ "schema": 1, "known": [{ "id", "issue",
118
+ "added", "sunset" }] }`; the central `{ "known": { "<repo>": [...] } }` shape in an app's file is refused (exit 2). Every app entry needs
119
+ an issue, an `added` date (YYYY-MM-DD, UTC, not later than today) and a `sunset` that is not before `added` and at most 90 days after
120
+ it. Past its sunset an entry stops tolerating its finding (the finding fails with `its baseline entry expired`) and is reported as
121
+ `EXPIRED`; a listed identity that no finding has any more is `STALE` and fails, so the list only shrinks. A rule the build refuses
122
+ whatever a baseline says (`switches-inventory`) fails even when listed.
123
+
82
124
  ## Declared test suites, web build, npm notices and artifact mirroring
83
125
 
84
126
  These replace per-app wrapper scripts. Each is declared in `right-release.config.mjs` and runs without a shell from the app
@@ -109,7 +151,7 @@ a JSON line before and after (`{"event":"e2e_suite_completed","suite":"tooling",
109
151
  (repeatable) selects suites; a group name such as `journeys` selects all its chunks. `--runner vitest -- run --project unit`
110
152
  runs a runner once with the arguments after `--` (watch mode, one project); `-- <args>` after `--suite` is allowed when exactly
111
153
  one suite is selected. `--list [--json]` prints the resolved commands and runs nothing. Wrap it as the repository needs, for
112
- example `rightkit storage run --app <app> -- right-release test`.
154
+ example `rustright storage run --app <app> -- right-release test`.
113
155
 
114
156
  ### `right-release build --web` (or `web-build`)
115
157
 
@@ -187,9 +229,27 @@ Only a target directory under a managed root (`resolveManagedRoots`: `RIGHTKIT_D
187
229
  `postSign`: it packs the signed, stapled `.app` and signs the tarball. The default output is `<App>.app.tar.gz`, named after the
188
230
  app in `notarize.app`. Declare the file in `updater.artifacts` as before. See `docs/MAC-PACKAGE-RECIPE.md`.
189
231
 
190
- ## QA receipts (warn-and-record)
232
+ ## QA receipts and admission (warn by default, refuse per app)
233
+
234
+ `right-release qa --stage source|installed --platform mac|win` runs `rightkit-qa` and writes a MAC'd receipt. Admission is a per-app policy in `qa-admission-policy.json`:
235
+
236
+ ```json
237
+ { "schema": 1,
238
+ "apps": { "postright": { "mode": "refuse", "since": "2026-11-02", "set_by": "adrian" } },
239
+ "approvedRunners": [ { "sha256": "<64 hex>", "version": "0.8.0" } ] }
240
+ ```
191
241
 
192
- `right-release qa --stage source|installed --platform mac|win` still runs `rightkit-qa` and writes a signed receipt, and receipt verification is unchanged. A missing or invalid receipt no longer blocks a release. `build`, `upload`, `publish-update` and `github` print `[right-release] QA warning: ...` to stderr, continue, and record the outcome:
242
+ - **`warn` (default; an app with no entry).** A missing or invalid receipt does not block. `build`, `upload`, `publish-update` and `github` print `[right-release] QA warning: ...` to stderr, continue, and record the outcome (below).
243
+ - **`refuse`.** The same refusal throws `QA admission refused for <app> (policy refuse since ... by ...)`; nothing is sealed, uploaded or published. Switch an app on with `node release-qa-policy.mjs set --app <app> --mode refuse --set-by <name>`. Moving an app back to `warn` (or deleting its entry) is refused unless `set_by` is `adrian`; a hand edit that does so is caught on every load by comparing with the copy committed at `RIGHT_RELEASE_QA_POLICY_BASELINE_REF` (default `HEAD`; CI should use `origin/main`). `set_by` is a recorded claim, not an authenticated identity: the file must be owner-protected in the repository (CODEOWNERS and branch protection), and a downgrade that was already committed becomes the new baseline. An unreadable or malformed policy file is a hard error, never a fall back to warn.
244
+
245
+ Hardening, applied in both modes (a failure is a warning in `warn` and a refusal in `refuse`):
246
+
247
+ 1. **Platform and release id.** The sealed receipt must be for the platform and the release id being sealed or published.
248
+ 2. **Approved runner.** The `rightkit-qa` executable's sha256 recorded in the receipt must be listed in `approvedRunners`. An empty list approves nothing. The binary is digested before the run and again after it; a swap during the run is refused.
249
+ 3. **Every release-critical journey.** With `qa.journeys` in the release config (`{ manifest?: "journeys.toml", receipt?: "<path>", args?: { source?, installed? } }`) `right-release qa` runs `rightkit-qa journeys run` (or reads `receipt`), validates the `rightkit.journeys.receipt/1` through `rightkit-qa journeys validate-receipt --manifest .. --tree ..` (exit code 0 and a final `ACCEPTED` line), and seals a `journeys` summary. `release-qa-floor.json` maps the `journey` floor id to "all release-critical journeys passed": at admission the required set is recomputed from the `journeys.toml` committed at the receipt's commit (every `release_critical` journey that lists the platform), and each must be `passed`. Zero critical journeys is never a pass. One matching check no longer satisfies the floor.
250
+ 4. **Signing outside test execution.** Checked: the receipt records the test processes' pids and the window they ran in; admission refuses a receipt sealed before that window ended, signing that began before it ended (`signingStartedAt`), a sealing or admitting process that is a test process, `RIGHT_RELEASE_QA_STAGE` set at admission (admission or signing inside a QA run), and `right-release qa` itself refuses to seal inside a QA run. Not checkable here: that test processes cannot reach the signing key (OS credential store and signing agent ACL) and that CI runs QA and signing as separate jobs (the carried receipt, rightkit#183, is the hook).
251
+
252
+ `right-release qa` keeps the older behavior for evidence checks and verification of the receipt itself; the outcome is recorded:
193
253
 
194
254
  - Source stage: sealed into the manifest as `qa: { status: "passed", sourceReceiptDigest, floorDigest }` or `qa: { status: "missing" | "rejected", reason }`.
195
255
  - Installed stage: written to `.right-release/state/<releaseId>/<platformDir>/qa-installed.json` as `{ status, reason?, receiptDigest?, checkedAt, provider }`. It is written when publication starts, so a later upload failure still leaves a record.
@@ -212,7 +272,7 @@ right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id d
212
272
 
213
273
  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.
214
274
 
215
- 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 `rightkit 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}`).
275
+ 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}`).
216
276
 
217
277
  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.
218
278
 
@@ -384,7 +444,7 @@ Updater artifacts are declared as for R2 but need no `key`; `platform` must be a
384
444
  ```js
385
445
  targets: {
386
446
  win: {
387
- updater: { keyFile: "%APPDATA%/RightKit/updater.key", artifacts: [
447
+ updater: { artifacts: [
388
448
  { file: "src-tauri/target/release/bundle/nsis/Ember_1.2.3_x64-setup.exe", signature: "src-tauri/target/release/bundle/nsis/Ember_1.2.3_x64-setup.exe.sig", platform: "windows-x86_64" },
389
449
  ] },
390
450
  artifacts: ["dist/Ember-1.2.3-portable-win.zip"], // portable zips seal alongside; they are not in latest.json
@@ -395,6 +455,8 @@ targets: {
395
455
  }
396
456
  ```
397
457
 
458
+ Updater signing keys are never named in the config: `sign-updater` reads the minisign key from the OS credential provider (`rightkit-credentials`, namespace `rightsuite-updater-key`; see `protected-credentials.mjs`). `targets.<platform>.updater.keyFile` is refused at config validation (`RELEASE_CONFIG_UPDATER_KEY_FILE`, listed by `right-release doctor` and the build with every other finding) and is never forwarded to the signer, so remove it from apps that still set it.
459
+
398
460
  NSIS, MSI, `.app.tar.gz` and AppImage bundles all work the same way (any file signed by `sign-updater`). With no sealed installer the installer platform-trust check is skipped (reported in the output); installer-bearing seals still run it. The app side: `plugins.updater.endpoints` is the `latest.json` URL above and `plugins.updater.pubkey` is the suite updater public key (see `@rightkit/app-update` README).
399
461
 
400
462
  ## Android (AAB build, Play internal track, dev install)
@@ -0,0 +1,279 @@
1
+ // The files tier of the admission (admission.mjs): refusals decided by the release config and the committed files alone.
2
+ // Each row is the predicate of a refusal the build used to raise later, after the lock, the install or the compile; the
3
+ // message is the build's own, so the doctor, the build and the worker say the same thing. A row names where its twin lives.
4
+ import path from "node:path";
5
+ import { assertGitHubRepository } from "./github-updater-release.mjs";
6
+ import { validateAndroidTarget } from "./android-release.mjs";
7
+ import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
8
+ import { dirtyBuildInputs } from "./release-invocation.mjs";
9
+ import { packageCommandOptional } from "./project-type.mjs";
10
+ import { planMediaSidecar, validateMediaSidecarConfig } from "./media-sidecar.mjs";
11
+ import { loadSidecarCatalog } from "./media-sidecar-catalog.mjs";
12
+
13
+ export const WINDOWS_NSIS_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-nsis-v1";
14
+ export const DISTRIBUTION_PROVIDERS = Object.freeze(["r2-rightapps", "github-releases"]);
15
+ const SAFE_APP = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
16
+ const SAFE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
17
+ // publish-update.mjs assertCurrentKey / assertVersionedKey: where the R2 objects of a private release live.
18
+ const CURRENT_UPDATER_KEY = /\/updates\/(?:mac|windows)\/current\//;
19
+ const CURRENT_INSTALLER_KEY = /\/installers\/(?:mac|windows)\/current\//;
20
+ const VERSIONED_INSTALLER_KEY = /\/installers\/(?:mac|windows)\/versions\/[^/]+\/[^/]+$/;
21
+
22
+ const isNsis = (ctx) => ctx.platform === "win" && ctx.target?.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT;
23
+ const asList = (value) => (Array.isArray(value) ? value : []);
24
+ const app = (ctx) => ctx.config?.app ?? "app";
25
+
26
+ // build-release.mjs (the controller) and release.mjs (the worker) read the same "which file is the app's package.json" answer.
27
+ const workdirOf = (ctx) => path.resolve(ctx.root, ctx.config?.workdir ?? ".");
28
+
29
+ export const FILES_ROWS = Object.freeze([
30
+ {
31
+ // build-release.mjs: the config must expose a release identity that is safe to put in paths and ids.
32
+ id: "config-identity",
33
+ tier: "files",
34
+ needsTarget: false,
35
+ run({ config }) {
36
+ const found = [];
37
+ if (config?.schema !== 1) found.push(`unsupported config schema: ${config?.schema ?? "<missing>"} (expected 1)`);
38
+ if (!config?.app || !config?.version) found.push("release config must expose app and version");
39
+ else if (!SAFE_APP.test(config.app) || !SAFE_VERSION.test(config.version)) found.push("app and version must be safe release-id components");
40
+ return found;
41
+ },
42
+ },
43
+ {
44
+ // release.mjs `has no <platform> package command`; build-release.mjs said the same after the lock.
45
+ id: "package-command",
46
+ tier: "files",
47
+ run(ctx) {
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;
50
+ return [`${app(ctx)} has no ${ctx.platform} package command`];
51
+ },
52
+ },
53
+ {
54
+ // release-invocation.mjs resolveReleaseBuildInputs: the doctor reports it as its own check, the build as this row.
55
+ id: "build-inputs",
56
+ tier: "files",
57
+ doctorOwned: true,
58
+ run(ctx) {
59
+ const inputs = ctx.buildInputs();
60
+ return inputs.ok ? undefined : [inputs.error.message];
61
+ },
62
+ },
63
+ {
64
+ // The files that can change the packaged app are committed: the doctor reports it as its own check, the build as this row.
65
+ id: "dirty-inputs",
66
+ tier: "files",
67
+ doctorOwned: true,
68
+ after: ["build-inputs"],
69
+ run(ctx) {
70
+ const inputs = ctx.buildInputs();
71
+ if (!inputs.ok) return { skipped: "build-inputs is reported on its own" };
72
+ const dirty = dirtyBuildInputs(ctx.repoRoot, inputs.value.buildInputs, ctx.commit());
73
+ return dirty.length > 0 ? [`dirty files can change the packaged app; resolve them before release:\n${dirty.map((file) => `- ${file}`).join("\n")}`] : undefined;
74
+ },
75
+ },
76
+ {
77
+ // build-release.mjs hashInputs: two inputs may not share a sealed hash key, or one silently replaces the other.
78
+ id: "input-key-collision",
79
+ tier: "files",
80
+ after: ["build-inputs"],
81
+ run(ctx) {
82
+ const inputs = ctx.buildInputs();
83
+ if (!inputs.ok) return { skipped: "build-inputs is reported on its own" };
84
+ const seen = new Set();
85
+ const collided = new Set();
86
+ for (const file of inputs.value.requiredInputs) {
87
+ const key = path.relative(path.dirname(path.dirname(file)), file);
88
+ if (seen.has(key)) collided.add(key);
89
+ seen.add(key);
90
+ }
91
+ return [...collided].map((key) => `release inputs share the hash key ${key}; rename one so every input is sealed`);
92
+ },
93
+ },
94
+ {
95
+ // build-release.mjs assertNsisInPlaceUpgradeContract: the NSIS template that upgrades in place. The build judges it on the target
96
+ // as the config declares it, so a winPackage recipe (which declares no signingContract) is not held to it today.
97
+ id: "nsis-upgrade-contract",
98
+ tier: "files",
99
+ run(ctx) {
100
+ if (ctx.platform !== "win" || ctx.rawTarget?.signingContract !== WINDOWS_NSIS_SIGNING_CONTRACT) return { na: "not a Windows NSIS target declared in the config" };
101
+ try { assertNsisInPlaceUpgradeContract(ctx.root, ctx.rawTarget.nsisUpgradeContract); } catch (error) { return [error.message]; }
102
+ return undefined;
103
+ },
104
+ },
105
+ {
106
+ // release.mjs, after the installer is signed: the post-code-signing minisign needs updater.artifacts to sign.
107
+ id: "win-updater-artifacts",
108
+ tier: "files",
109
+ run(ctx) {
110
+ if (!isNsis(ctx)) return { na: "not a Windows NSIS target" };
111
+ return asList(ctx.target.updater?.artifacts).length > 0 ? undefined : [`${app(ctx)} win must declare updater.artifacts for post-code-signing minisign`];
112
+ },
113
+ },
114
+ {
115
+ // build-release.mjs sealRelease: what the seal needs from the config, evaluated before anything is compiled or signed.
116
+ id: "seal-routes",
117
+ tier: "files",
118
+ run(ctx) {
119
+ const { target, platform, config } = ctx;
120
+ const found = [];
121
+ const declared = asList(target.artifacts);
122
+ for (const entry of declared) {
123
+ const file = typeof entry === "string" ? entry : entry?.file;
124
+ if (typeof file !== "string" || !file) { found.push("target.artifacts entries must be paths or { file }"); break; }
125
+ }
126
+ const installers = asList(target.installer?.artifacts);
127
+ const updaters = asList(target.updater?.artifacts);
128
+ if (installers.length + updaters.length + declared.length === 0) found.push(`nothing to seal: declare installer.artifacts, updater.artifacts or artifacts for the ${platform} target`);
129
+ for (const entry of [...installers, ...updaters]) {
130
+ if (typeof entry?.file !== "string" || !entry.file) { found.push("installer and updater artifacts must each declare a file"); break; }
131
+ }
132
+ const minisign = updaters.length > 0 && target.updater?.kind !== "appinstaller";
133
+ if (minisign) {
134
+ const publicKey = config.distribution?.updaterPublicKey ?? config.distribution?.updaterPubkey ?? target.updater?.pubkey ?? target.updater?.publicKey;
135
+ if (typeof publicKey !== "string" || !publicKey.trim()) found.push("updater public key is required to verify updater artifacts before sealing");
136
+ for (const entry of updaters) {
137
+ if (typeof entry?.signature !== "string" || !entry.signature) found.push(`updater artifact ${entry?.file} must declare the signature file sign-updater writes next to it`);
138
+ }
139
+ }
140
+ const github = config.distribution?.provider === "github-releases";
141
+ for (const entry of updaters) {
142
+ if (entry?.key === undefined && !github) found.push(`updater artifact ${entry?.file} must declare the R2 route key (key); only github-releases names the route from the file`);
143
+ }
144
+ // The R2 keys publish-update.mjs checks at upload, tier by tier. Only a private release (r2-rightapps) is held to them. Both
145
+ // tiers check each updater's current key; only the patch tier checks the installer keys and where the patch updater lands, and
146
+ // it cannot run without installer.artifacts ("patch has no installer.artifacts"). The build is tier-neutral, so the patch
147
+ // checks apply exactly when the config makes the patch tier possible.
148
+ if (config.distribution?.provider === "r2-rightapps") {
149
+ const patchTier = installers.length > 0;
150
+ if (patchTier) {
151
+ for (const entry of installers) {
152
+ // publish-update.mjs assertCurrentKey tests the key as it is, so an installer with no key is refused with "got: undefined".
153
+ if (!CURRENT_INSTALLER_KEY.test(entry?.key)) found.push(`installer key must target the replaceable current R2 object, got: ${entry?.key}`);
154
+ if (entry?.versionedKey !== undefined && !VERSIONED_INSTALLER_KEY.test(entry.versionedKey)) found.push(`installer versionedKey must be <app>/installers/<os>/versions/<version>/<file>, got: ${entry.versionedKey}`);
155
+ }
156
+ }
157
+ for (const entry of updaters) {
158
+ if (typeof entry.key !== "string") continue;
159
+ if (!CURRENT_UPDATER_KEY.test(entry.key)) found.push(`updater key must target the replaceable current R2 object, got: ${entry.key}`);
160
+ if (!patchTier) continue;
161
+ // publish-update.mjs patchInstallerKey: the patch updater is uploaded as an installer, under patchKey or the updater key's installers twin.
162
+ const patchKey = entry.patchKey ? entry.patchKey : entry.key.includes("/updates/") ? entry.key.replace("/updates/", "/installers/") : null;
163
+ if (patchKey === null) found.push(`patch updater key must be convertible to installers/current, got: ${entry.key}`);
164
+ else if (!CURRENT_INSTALLER_KEY.test(patchKey)) found.push(`installer key must target the replaceable current R2 object, got: ${patchKey}`);
165
+ }
166
+ }
167
+ // The Android and iOS seals pin one file of target.artifacts (the AAB, the IPA).
168
+ const listed = declared.map((entry) => path.posix.normalize(String(typeof entry === "string" ? entry : entry?.file).replaceAll("\\", "/")));
169
+ if (platform === "android") {
170
+ let aab = null;
171
+ try { aab = validateAndroidTarget(target, { root: ctx.root }).aab?.file ?? null; } catch { /* the android-target check reports a bad target */ }
172
+ if (aab && !listed.includes(path.posix.normalize(aab.replaceAll("\\", "/")))) found.push("android seal requires targets.android.artifacts to list aab.file");
173
+ }
174
+ if (platform === "ios") {
175
+ const ipa = target.recipe?.spec?.ipa;
176
+ if (typeof ipa === "string" && !listed.includes(path.posix.normalize(ipa.replaceAll("\\", "/")))) found.push("ios seal requires targets.ios.artifacts to list iosPackage.ipa");
177
+ }
178
+ return found;
179
+ },
180
+ },
181
+ {
182
+ // publish-update.mjs: the provider the upload takes, for the win and mac releases it uploads (iOS and Android go through their own
183
+ // lanes, whose providers are app-store-connect and google-play). A config that declares no distribution is refused there too, as
184
+ // is a provider it does not know; preflight G16 refuses an unknown provider in the config text.
185
+ id: "distribution-provider",
186
+ tier: "files",
187
+ needsTarget: false,
188
+ run({ config, platform }) {
189
+ if (platform !== "win" && platform !== "mac") return { na: "publish-update.mjs uploads win and mac releases; other platforms upload through their own lane" };
190
+ const distribution = config.distribution;
191
+ const provider = distribution?.provider;
192
+ if (!DISTRIBUTION_PROVIDERS.includes(provider)) return [`release config distribution.provider must be ${DISTRIBUTION_PROVIDERS.join(" or ")}${provider === undefined ? "" : ` (got ${JSON.stringify(provider)})`}`];
193
+ if (provider === "github-releases") {
194
+ try { assertGitHubRepository(distribution.repository); } catch (error) { return [error.message]; }
195
+ }
196
+ return undefined;
197
+ },
198
+ },
199
+ {
200
+ // release.mjs frontendBundleDirs: the mock scan locates the frontend bundle from src-tauri/tauri.conf.json.
201
+ id: "frontend-config-readable",
202
+ tier: "files",
203
+ run(ctx) {
204
+ if (Array.isArray(ctx.target.webBundleScan)) return { na: "target.webBundleScan names the bundle directories" };
205
+ const confPath = path.join(ctx.root, "src-tauri", "tauri.conf.json");
206
+ if (!ctx.exists(confPath)) return { na: "no src-tauri/tauri.conf.json" };
207
+ try { JSON.parse(ctx.fs.readFileSync(confPath, "utf8")); } catch (error) {
208
+ return [`src-tauri/tauri.conf.json is unreadable, so the frontend bundle cannot be located for the mock scan: ${error.message}`];
209
+ }
210
+ return undefined;
211
+ },
212
+ },
213
+ {
214
+ // release-checks.mjs: every config.checks entry runs `<packageManager> run <entry>` after the install; a script that is not
215
+ // in package.json fails there, minutes in.
216
+ id: "declared-checks-exist",
217
+ tier: "files",
218
+ needsTarget: false,
219
+ run(ctx) {
220
+ const checks = asList(ctx.config.checks).filter((script) => script !== false);
221
+ if (checks.length === 0) return { na: "the config declares no checks" };
222
+ if (ctx.skipChecks) return { na: "the build was asked to skip the declared checks" };
223
+ const file = path.join(workdirOf(ctx), "package.json");
224
+ let scripts;
225
+ try { scripts = JSON.parse(ctx.fs.readFileSync(file, "utf8")).scripts ?? {}; } catch {
226
+ return [`config.checks declares ${checks.map((script) => JSON.stringify(script)).join(", ")} but ${file} is missing or unreadable`];
227
+ }
228
+ return checks.filter((script) => typeof script !== "string" || !Object.hasOwn(scripts, script))
229
+ .map((script) => `config.checks names ${JSON.stringify(script)}, which is not a script in ${path.relative(ctx.root, file).replaceAll("\\", "/") || "package.json"}`);
230
+ },
231
+ },
232
+ {
233
+ // release.mjs: a mediaSidecar or externalBin on macOS is verified inside the built .app, so the signing set must name one.
234
+ id: "sidecar-app-entry",
235
+ tier: "files",
236
+ run(ctx) {
237
+ if (ctx.platform !== "mac" || (ctx.target.mediaSidecar === undefined && ctx.target.externalBin === undefined)) return { na: "no mac sidecar" };
238
+ const hasApp = asList(ctx.target.sign?.prePackageFiles).some((entry) => /\.app\/?$/.test(typeof entry === "string" ? entry : String(entry?.file ?? "")));
239
+ if (hasApp) return undefined;
240
+ return [ctx.target.mediaSidecar !== undefined
241
+ ? "mediaSidecar on macOS needs an .app entry in sign.prePackageFiles"
242
+ : "externalBin on macOS needs an .app entry in sign.prePackageFiles"];
243
+ },
244
+ },
245
+ {
246
+ // release.mjs prepareMediaSidecar: the sidecar plan is made from the catalog before anything is staged or compiled. The two parts
247
+ // the committed files decide: the app has a native Cargo manifest to resolve the catalog from, and the catalog the config names
248
+ // plans (a row for each tool, approved, with its digest and notices). A catalog read through `cargo metadata` is the host row
249
+ // media-sidecar-catalog. An invalid mediaSidecar config is the media-sidecar rule's finding, not this row's.
250
+ id: "media-sidecar-plan",
251
+ tier: "files",
252
+ after: ["build-inputs"],
253
+ run(ctx) {
254
+ if (ctx.target.mediaSidecar === undefined || (ctx.platform !== "mac" && ctx.platform !== "win")) return { na: "the target declares no mediaSidecar" };
255
+ let sidecar;
256
+ try { sidecar = validateMediaSidecarConfig(ctx.target.mediaSidecar, { platform: ctx.platform }); } catch { return { skipped: "an invalid mediaSidecar configuration is reported by the media-sidecar rule" }; }
257
+ const inputs = ctx.buildInputs();
258
+ if (!inputs.ok) return { skipped: "build-inputs is reported on its own" };
259
+ const layout = inputs.value.nativeLayout;
260
+ // Unreachable for iOS (mediaSidecar is mac and win only); a Cargo-less layout must still never be dereferenced.
261
+ if (!layout) return [`${app(ctx)} ${ctx.platform} mediaSidecar needs a native Cargo manifest to read the sidecar catalog, and this app has none`];
262
+ if (!sidecar.catalog) return { skipped: "the catalog is resolved through cargo metadata, which the media-sidecar-catalog host row asks" };
263
+ try {
264
+ const loaded = loadSidecarCatalog({ config: sidecar, root: ctx.root, manifestPath: layout.manifestPath, read: (file) => ctx.fs.readFileSync(file) });
265
+ planMediaSidecar({ config: sidecar, ...loaded, root: ctx.root });
266
+ } catch (error) { return [`${app(ctx)} ${ctx.platform} mediaSidecar: [${error.code ?? "ERROR"}] ${error.message}`]; }
267
+ return undefined;
268
+ },
269
+ },
270
+ {
271
+ // build-release.mjs runConfiguredReleaseCheck: the preSeal check runs a command.
272
+ id: "pre-seal-command",
273
+ tier: "files",
274
+ run(ctx) {
275
+ if (!ctx.target.preSeal) return { na: "no preSeal check" };
276
+ return typeof ctx.target.preSeal.cmd === "string" && ctx.target.preSeal.cmd.trim() ? undefined : ["release preSeal check must declare a command"];
277
+ },
278
+ },
279
+ ]);