@rightkit/release 0.6.4 → 0.6.6
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.
- package/README.md +109 -7
- package/admission-files.mjs +279 -0
- package/admission-host.mjs +403 -0
- package/admission.mjs +146 -0
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +1326 -0
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +495 -69
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +87 -26
- package/cache-command.mjs +4 -3
- package/cargo-license-notices.mjs +2 -1
- package/cargo-manifest-text.mjs +25 -0
- package/cargo-profile-contract.mjs +26 -13
- package/cli/right-release.mjs +45 -5
- package/command-spec.mjs +4 -5
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +301 -37
- package/development.mjs +5 -5
- package/github-release.mjs +5 -3
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +134 -8
- package/inventory-check.mjs +12 -1
- package/ios-upload.mjs +4 -4
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -0
- package/ownership-dependency-allowlist.json +2 -0
- package/package.json +41 -3
- package/pipeline-admission.mjs +12 -0
- package/prebuild-gates.mjs +7 -3
- package/publish-cargo.mjs +215 -3
- package/publish-update.mjs +5 -3
- package/release-config.mjs +36 -5
- package/release-dependencies-command.mjs +4 -3
- package/release-qa-audit.mjs +12 -10
- package/release-qa-command.mjs +42 -7
- package/release-qa-hardening.mjs +10 -3
- package/release-qa-policy.mjs +214 -47
- package/release.mjs +117 -62
- package/rightkit-registry-resolver.mjs +5 -2
- package/rightkit-versions.json +18 -13
- package/stage-sidecar-command.mjs +5 -5
- package/test-command.mjs +3 -2
- package/upload-release.mjs +3 -2
- package/web-build.mjs +2 -1
package/README.md
CHANGED
|
@@ -86,11 +86,101 @@ On desktop (non-iOS) targets the source walk covers the whole app root, so app t
|
|
|
86
86
|
| Candidate signing | `sign-candidate --platform win\|mac --artifact <dir> --candidate-receipt <receipt.json> --out <binding.json> [--manifest <sign.json>] [--publisher <subject>] [--dry-run]`: signs the exact unsigned candidate bytes in place instead of rebuilding from source. The candidate receipt (`{ schema: 1, files: [{ path, sha256 }] }`) is checked first and any mismatch refuses; Windows signs through Azure Artifact Signing (`--publisher` pins the MSIX signer subject), macOS through Developer ID with fixed identifiers from `--manifest`. Writes a binding receipt (`rebuilt: false`, candidate sha -> signed sha per file). Never builds, notarizes or publishes |
|
|
87
87
|
| Dev artifact signing | `sign-dev-artifact --artifact <dir> --manifest <sign.json> [--metadata <payload.json>] [--evidence <path>] [--dry-run]`: Developer ID signs an existing CI dev artifact per file with fixed identifiers (macOS). It verifies the recorded hashes in the payload metadata (default `payload.json`, `{ schema: 1, files: [{ path, sha256 }] }`) first, signs each sign-manifest entry (`file` relative to the artifact, `identifier` required, nested code before its bundle) through the dev-signing identity (missing Developer ID: `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`, no ad hoc fallback), verifies with `codesign --verify --strict`, reads back Identifier and TeamIdentifier, refreshes the metadata hashes, and writes evidence JSON. Never builds, notarizes or publishes. The payload metadata must sit outside every signed entry (`SIGN_DEV_ARTIFACT_METADATA_IN_SIGNED_ENTRY`). An optional declarative `refresh` in the sign manifest recomputes digests that other JSON files record about signed bytes: `{ "refreshRoot": "<dir inside the artifact>", "refresh": [{ "file", "pointer" (JSON pointer to an existing string), "compute": "sha256-file" \| "sha256-tree-v1" \| "verify-sha256-file", "of", "format"?, "currentMatches"? }] }` (`file` and `of` relative to `refreshRoot`; `sha256-tree-v1` = sha256 over the sorted relative paths, each as path, NUL, 8-byte big-endian length, bytes; `verify-sha256-file` writes nothing and refuses a digest that changed; `format` embeds the digest in a locator string through one `{value}`; `currentMatches` refuses to overwrite a value of another kind, such as a signed attestation locator). Order is enforced: operations run after the entries they read are signed and before the first entry that contains a refreshed file, so that bundle (the wrapper) signs last over the final bytes; an operation may not read what a later one writes. JSON is rewritten in place with key order kept, and a file the writer would have to reformat is refused. Evidence gains a `refresh` list |
|
|
88
88
|
| Checks | `deps`, `hardening`, `harden`, `hardeningscan`, `cargo-target` |
|
|
89
|
+
| Update | `update check\|apply\|gate\|land\|discard\|run\|schedule [--repo <path>]`: runs the `right-update` bin of the `@rightkit/outdated` this package pins (an exact dependency), because pnpm links only an app's direct dependencies' bins and no app has `right-update` on its path. It reads `.rightkit/update.json`; `pnpm exec right-release update run` is what a stale-pin refusal tells an app to run. A missing updater exits 2 |
|
|
89
90
|
| Ownership | `ownership`, `ownership-approval`, `ownership-report`, `ownership-mandatory`, `adoption-evidence` |
|
|
90
91
|
| Test | `test` (the app's declared suites) |
|
|
91
92
|
| Legal | `license-notices npm`, `license-notices cargo`, `license-notices binaries` |
|
|
92
93
|
| Other | `dev`, `doctor`, `suite-doctor`, `qa`, `dmg`, `cache`, `apple`, `inventory`, `model`, `addon`, `hooks`, `lsclean`, `generate-dmg-background`, `publish cargo`, `publish npm`, `publish swift` |
|
|
93
94
|
|
|
95
|
+
## The app check: deterministic first, no flag skips it
|
|
96
|
+
|
|
97
|
+
`right-release check [--repo <dir>] [--config <file>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]`
|
|
98
|
+
is the offline app check, the same as `rightkit-gate check`: it reads files only (the committed tree of `--commit`, default HEAD, or with
|
|
99
|
+
`--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,
|
|
100
|
+
1 has findings, 2 could not run (a repository with no commit yet says `the repository has no commit yet; commit first`). The repository
|
|
101
|
+
is the git top-level of `--config`'s folder, else of `--app-root` (`recipe-build`), else of the working directory.
|
|
102
|
+
|
|
103
|
+
The commands that compile, package, sign, ship or inspect a release run it first, in the same process, and start nothing when it is
|
|
104
|
+
red or cannot run (outside a git repository it is red). There is no flag that skips it: `--help`, `--dry-run`, `--check` and `--list`
|
|
105
|
+
only change what a command does after the check.
|
|
106
|
+
|
|
107
|
+
| Mode | Commands |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `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` |
|
|
110
|
+
| `worktree` (the tree on disk, as a compile sees it) | `dev`, `doctor`, `test`, `stage-sidecar`, `sign-dev-artifact` |
|
|
111
|
+
|
|
112
|
+
Not guarded: the report forms of guarded commands (`publish cargo|npm|swift`, `qa audit`, `doctor --all`) and every other command.
|
|
113
|
+
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
|
|
114
|
+
neither list. `dmg` takes `--app`/`--out` paths, compiles nothing and has no repository; `addon` is checked only as `addon build` (it
|
|
115
|
+
runs the add-on's own build command), its `doctor`, `upload` and `adopt` forms compile nothing.
|
|
116
|
+
|
|
117
|
+
The rules (`right-release check --list` prints their ids and gaps; `scripts/preflight.mjs` adds RightKit's own):
|
|
118
|
+
|
|
119
|
+
| Rule | Gap | Finds |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `eol-policy` | G04 | a committed blob with CRLF where `.gitattributes` says `eol=lf` |
|
|
122
|
+
| `file-url` | G12 | the `.pathname` of a URL built from `import.meta.url` used as a path (wrong on Windows; use `fileURLToPath`) |
|
|
123
|
+
| `retired-alias` | P11 | an invocation of `rightkit`, the retired name of the RustRight client, outside history |
|
|
124
|
+
| `qa-platform` | G13 | a Rust `"platform:<x>"` requirement that is not `darwin` or `windows` (the test skips forever) |
|
|
125
|
+
| `test-harness` | G14 | a Cargo `[[test]]` whose file defines `fn main(` but lacks `harness = false` (libtest runs 0 tests) |
|
|
126
|
+
| `scenario-skip` | G15 | a `.scenario(` test that prints SKIP and returns (a skip that reports green) |
|
|
127
|
+
| `release-config` | G16 | `right-release.config.json`, read as data (or a legacy `right-release.config.mjs`): a provider `publish-update.mjs` refuses, an updater `keyFile`, an undefined Cargo profile; JSON that `right-release` cannot load is a finding |
|
|
128
|
+
| `skip-checks` | G17 | a `package.json` script that runs `right-release` with `--skip-checks` |
|
|
129
|
+
| `qa-dev-server` | G18 | a `package.json` `qa*` script that runs `tauri dev` (QA would test the dev server, not the app) |
|
|
130
|
+
| `ffmpeg-release-escape` | G19 | `allow_in_release_builds` in non-test Rust outside `crates/rightkit-ffmpeg` |
|
|
131
|
+
| `switches-inventory` | G20 | a directory with a `right-release.config.json`, read as data (or a legacy `right-release.config.mjs`), and no `switches.json` (or its `releaseSwitches.inventory`) |
|
|
132
|
+
| `speech-link` | G21 | `import Speech` under `swift/RightKitSwift/Sources` or `Tests` |
|
|
133
|
+
| `e2e-under-cargo-test` | G22 | `cargo test`, `cargo run` or `cargo nextest` (plain, `rustright cargo`, or the retired `rightkit`) that runs an end-to-end crate in the broker: it holds the compile slot for the whole run |
|
|
134
|
+
|
|
135
|
+
`e2e-under-cargo-test` (G22): the broker compiles; it does not run end-to-end tests. An end-to-end crate is a Cargo package that depends on
|
|
136
|
+
`rightkit-qa` not optionally (the optional dependency is an app's in-app control server) and has a `.rs` file outside `examples/` that names
|
|
137
|
+
a driver module of it (`harness`, `control`, `suite`, `scenario`, `engine`, `in_app`, `interactive`, `page`, `browser`, `android`); a name
|
|
138
|
+
like `*-e2e` is not evidence. A command selects it by `-p`/`--package`, `--manifest-path`, `--workspace`/`--all` (the members of the
|
|
139
|
+
workspace in the working directory, less `--exclude`), `--test`, or for `cargo run` `--bin`. It looks in `package.json` scripts, shell,
|
|
140
|
+
PowerShell, batch, Node and Python scripts, Makefile and justfile recipes, workflow `run:` steps, git hooks, and code in `README.md`,
|
|
141
|
+
`AGENTS.md`, `CLAUDE.md` and `docs/` (history documents excluded). `--no-run`, `rightkit-qa run-tests`, `rightkit-qa journeys run` and
|
|
142
|
+
`--test qa_surface_guard` are not findings. The identity is `e2e-under-cargo-test|<file>|<script or crate>`. Fix: compile with `--no-run`, then
|
|
143
|
+
`rightkit-qa run-tests -p <crate> [--test <target>] -- <libtest args>`.
|
|
144
|
+
|
|
145
|
+
Known findings are tolerated only through a baseline: `--baseline <file>`, else the checked tree's `.rightkit/app-check-baseline.json`,
|
|
146
|
+
else (from a RightKit checkout only) `ci/app-check-baseline.json`. The app's own file is always `{ "schema": 1, "known": [{ "id", "issue",
|
|
147
|
+
"added", "sunset" }] }`; the central `{ "known": { "<repo>": [...] } }` shape in an app's file is refused (exit 2). Every app entry needs
|
|
148
|
+
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
|
|
149
|
+
it. Past its sunset an entry stops tolerating its finding (the finding fails with `its baseline entry expired`) and is reported as
|
|
150
|
+
`EXPIRED`; a listed identity that no finding has any more is `STALE` and fails, so the list only shrinks. A rule the build refuses
|
|
151
|
+
whatever a baseline says (`switches-inventory`) fails even when listed.
|
|
152
|
+
|
|
153
|
+
## The pre-push gate on a host without the broker: the Rust gate is deferred, the HOST RECEIPT gates the merge
|
|
154
|
+
|
|
155
|
+
On a host with neither `rustright` nor the managed cargo shim on PATH (a cloud session), `rightkit-gate` defers the Rust gate to host qualification instead of refusing when git's stdin names only non-protected branches (not `main`, `master`, `trunk`, `release/*`, the `.rightgit.json` `defaultBranch` or a tag) and the process is not in CI (`CI` and `GITHUB_ACTIONS` unset): it reads the Rust units from the tracked manifests, runs every scan that needs no broker, prints `rightkit-gate: rust gate DEFERRED to host qualification (no RustRight broker on this host): <Rust units>; scans not run <ids>; the pull request needs a HOST RECEIPT before merge`, exits 0 and writes nothing into the tree. The pull request still needs a HOST RECEIPT (a host with the RustRight broker ran the full gate) before it merges, and that receipt, not the hook, remains the merge gate; a protected branch, a host with the broker or the shim, CI, a manual run or `--plan`, and `RIGHTKIT_GATE_NO_DEFER=1` keep the refusal exactly as before, with one added line saying why nothing was deferred (the protected ref, CI or the opt-out). 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. The default branch is read from the checkout, the base ref, the remote commits being replaced and `origin/HEAD`, never from the pushed commit alone (when none of those can be read, which takes a force-push whose replaced tip was never fetched in a repository with no `origin/HEAD`, the checkout's own manifest is all there is). The units come from a manifest reader, not cargo, and a standalone package (a single crate, a Tauri `src-tauri`, several packages with no `[workspace]`) is a unit like any other: when tracked manifests exist and it finds no unit, or finds a package it cannot place, the block says so instead of reporting that no Rust scan was due. An npm test scan (`test:npm:<unit>`) whose `test` script runs `rustright` or `cargo`, directly or through the `pnpm <script>`, `pnpm run`, `npm run` or `yarn` scripts of the same package.json (and their `pre`/`post` hooks; ViewRight's `test:e2e` is `rustright storage run ... -- right-release test`), cannot run on such a host either, so it is deferred with the Rust gate: named in the `scans not run` list and on a line of its own with the script chain and the command, and never reported as passed. On a host with the broker or the shim, in CI, on a protected ref and with `RIGHTKIT_GATE_NO_DEFER=1` it runs as before.
|
|
156
|
+
|
|
157
|
+
## The rustfmt scan reads each file's edition from its crate
|
|
158
|
+
|
|
159
|
+
The class A `rustfmt:<edition>` scans group the changed `.rs` files by the edition of the crate they belong to: the nearest `Cargo.toml` at or above the file decides. A literal `edition = "NNNN"` in its `[package]` table is that edition. `edition.workspace = true` (or `edition = { workspace = true }`) takes the `[workspace.package] edition` of the owning workspace root, the nearest `Cargo.toml` at or above the crate that declares `[workspace]` (the crate's own manifest, for a root package that is also the workspace). When the inheritance cannot be followed (no workspace root, a root with no literal `[workspace.package] edition`, a manifest in the chain that cannot be read, or an `edition` entry in a spelling the scan does not recognise such as `edition = 2021`) the gate fails with a `path-policy:*` finding that names the manifest and the reason, once per manifest, and those files are not checked under a guessed edition. Fix the manifest (declare `[workspace.package] edition`, or write the edition in the crate). A manifest that names no edition at all, and a `.rs` file below no manifest, are checked as 2021.
|
|
160
|
+
|
|
161
|
+
## The repository secret scan: reviewed records, not guesses
|
|
162
|
+
|
|
163
|
+
The class A `secret-scan` scan reads every file of each changed crate and package. Crate packaging, the package archive and the local-publish batch scan are strict: every `.env*` file, key file (`*.pem`, `*.p8`, `*.p12`, `*.key`) and env template is refused by name, and content that matches the credential shapes (`SECRET_PATTERNS`: a PEM private key header, `AKIA...`, `gh[pousr]_...`, `npm_...`) is refused. The repository gate (`scanCrateForSecrets(dir, { envTemplates: true, repoRoot })`) is the only mode that reads the repository's records, `.rightkit/secret-scan-records.json` of the gated commit:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{ "schema": 1, "records": [
|
|
167
|
+
{ "path": ".env.example", "sha256": "<hex of the bytes, CRLF read as LF>", "kind": "env-template", "note": "local development defaults only, no credential" },
|
|
168
|
+
{ "path": "engine/src/redaction.rs", "sha256": "<hex>", "kind": "fixture", "note": "redaction test vectors, fake values" }
|
|
169
|
+
] }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| Finding | Admitted by | Never admitted |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| An env template (`.env.example`, `.env.<name>.example`, `.env.sample`, `.env.template`) | a record of kind `env-template` whose `sha256` is the file's | a template that is not UTF-8 (UTF-16 byte-order mark, NUL bytes, invalid sequence); one that holds a PEM private key header, `github_pat_...`, `sk_live_...`, `xox[abprs]-...`, `AIza...`, a Slack webhook URL or a `SECRET_PATTERNS` shape; one inside a publishable crate (its `[package]` does not say `publish = false`) or package (`package.json` not `"private": true`) |
|
|
175
|
+
| Any other file whose content matches `SECRET_PATTERNS` (a redaction or screening test vector) | a record of kind `fixture` whose `sha256` is the file's | |
|
|
176
|
+
| `.env`, `.env.local`, any other `.env.*` that is not a template, `*.pem`, `*.p8`, `*.p12`, `*.key` | nothing: refused by name, and a record naming one is itself a violation | any |
|
|
177
|
+
|
|
178
|
+
A violation reads `<path>[:<line>]: <reason>`. For a template or a fixture it carries the file's sha256 and the exact record to add, with a placeholder note: review the file, write the note (what it holds and why it is safe; the placeholder is refused), and commit the record. Any edit to a recorded file changes its sha256, so the file fails again until its record is replaced, and the change of the record is the review in the pull request diff. A violation names the line and the kind of shape a hit matched, never the matched text. A malformed records file is a violation: bad JSON, a schema other than 1, an unknown key or kind, a missing, empty or placeholder note, a `sha256` that is not 64 hex characters, a `path` that is not a normalised repository-relative path, a duplicate path, a kind that does not fit the path (`env-template` for a file that is not a template, `fixture` for a template). A record with a problem admits nothing, and the file must be a regular file inside the repository (a symbolic link is refused). Records are keyed by the repository path, so a moved file needs a new record, and the records file is scanned like any other file, so a note must not quote a credential.
|
|
179
|
+
|
|
180
|
+
## The npm scans the gate plans
|
|
181
|
+
|
|
182
|
+
The gate takes the npm layout from the tracked files. Every pnpm root is discovered: the repository root, and any other directory outside `node_modules` (at most 6 levels deep, at most 64 roots) that holds a `pnpm-lock.yaml` or a `pnpm-workspace.yaml` beside a `package.json`. CodeRight has two roots, `apps/coderight-tauri` and `apps/coderight-web`, and no package at the repository root. Each root gets `pnpm-lockfile` and `pnpm-audit` run in its own directory (`pnpm-lockfile:<root>` and `pnpm-audit:<root>` below the repository root), planned when a unit of the root is in scope or its own package.json, lock or workspace file changed; each unit gets `tsc:<unit>`, a linter and `test:npm:<unit>`. A unit of the repository root keeps its package name in those ids; a unit of any other root is named from its directory (`apps-coderight-web`), so its ids carry the root path. A repository with one root plans what it always did. A unit is linted by the tool it configures: `eslint:<unit>` where an ESLint config (`eslint.config.*`, `.eslintrc*`) exists in its directory or any directory above it, `biome:<unit>` (`pnpm exec biome lint .`) where a `biome.json` or `biome.jsonc` does, both where both do and neither where neither does.
|
|
183
|
+
|
|
94
184
|
## Declared test suites, web build, npm notices and artifact mirroring
|
|
95
185
|
|
|
96
186
|
These replace per-app wrapper scripts. Each is declared in `right-release.config.mjs` and runs without a shell from the app
|
|
@@ -205,17 +295,24 @@ app in `notarize.app`. Declare the file in `updater.artifacts` as before. See `d
|
|
|
205
295
|
|
|
206
296
|
```json
|
|
207
297
|
{ "schema": 1,
|
|
208
|
-
"apps": {
|
|
209
|
-
|
|
298
|
+
"apps": {
|
|
299
|
+
"postright": { "mode": "refuse", "since": "2026-11-02", "set_by": "adrian" },
|
|
300
|
+
"genright": { "mode": "warn", "since": "2026-10-24", "set_by": "adrian",
|
|
301
|
+
"stages": { "source": { "mode": "refuse", "since": "2026-11-09", "set_by": "adrian" } } } },
|
|
302
|
+
"approvedRunners": [ { "sha256": "<64 hex>", "version": "0.9.0", "platform": "mac", "set_by": "adrian", "since": "2026-10-24" } ] }
|
|
210
303
|
```
|
|
211
304
|
|
|
305
|
+
**Per-stage modes.** An app entry is one mode for both stages, or carries `stages: { source?, installed? }` entries that override it for that stage (`source` is the build; `installed` is upload, publish-update and github). The `installed` stage cannot pass until the installed lane exists, so move an app to `refuse` for `source` first: `qa policy set --app <app> --stage source --mode refuse --set-by <name>`. Without `--stage` one mode governs both stages and any `stages` object is dropped. A stage that was `refuse` and is no longer (removed, or `warn`) needs `set_by: "adrian"`, checked per stage on write and on load.
|
|
306
|
+
|
|
212
307
|
- **`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).
|
|
213
|
-
- **`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 `
|
|
308
|
+
- **`refuse`.** The same refusal throws `QA admission refused for <app> (policy refuse since ... by ...)`; nothing is sealed, uploaded or published. Switch an app on with `right-release qa policy set --app <app> --mode refuse --set-by <name> [--stage source|installed]` (or `node release-qa-policy.mjs set ...`). Moving an app back to `warn` (or deleting its entry) is refused unless `set_by` is `adrian`; a hand edit that does so is caught on every load by comparing with the copy committed at `RIGHT_RELEASE_QA_POLICY_BASELINE_REF` (default `HEAD`; CI should use `origin/main`). `set_by` is a recorded claim, not an authenticated identity: the file must be owner-protected in the repository (CODEOWNERS and branch protection), and a downgrade that was already committed becomes the new baseline. An unreadable or malformed policy file is a hard error, never a fall back to warn. When a baseline ref is requested (`RIGHT_RELEASE_QA_POLICY_BASELINE_REF` or the `baselineRef` argument) and git cannot show the policy at that ref, the load fails closed instead of running without a baseline; with nothing requested a checkout without that history (an app's `node_modules`) still loads with no baseline.
|
|
309
|
+
|
|
310
|
+
**Approving a runner.** `right-release qa policy approve-runner --runner <file> --version <v> --platform mac|win --set-by adrian [--note <text>]` digests the file, runs `<file> --version` (the output must be exactly `rightkit-qa <v>`), requires `<v>` to be the published `rightkit-qa` in `rightkit-versions.json` (`registry.cargo`), re-digests the file to catch a swap while it ran, and appends `{ sha256, version, platform, set_by, since, note? }`. Only `adrian` may approve (a runner entry is new admission authority; a hand-added entry by anyone else is refused on load against the baseline). An approval is per platform because a runner's digest differs per host and OS; an entry with no `platform` (older files) approves the digest on either. Running the approved binary is the caller's job: set `qa.runner` in the release config to the managed path, since a bare `which rightkit-qa` can resolve to a different build.
|
|
214
311
|
|
|
215
312
|
Hardening, applied in both modes (a failure is a warning in `warn` and a refusal in `refuse`):
|
|
216
313
|
|
|
217
314
|
1. **Platform and release id.** The sealed receipt must be for the platform and the release id being sealed or published.
|
|
218
|
-
2. **Approved runner.** The `rightkit-qa` executable's sha256 recorded in the receipt must be listed in `approvedRunners
|
|
315
|
+
2. **Approved runner.** The `rightkit-qa` executable's sha256 recorded in the receipt must be listed in `approvedRunners` for the platform being sealed or published (an entry without `platform` approves either). An empty list approves nothing. The binary is digested before the run and again after it; a swap during the run is refused.
|
|
219
316
|
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.
|
|
220
317
|
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).
|
|
221
318
|
|
|
@@ -308,12 +405,15 @@ enforcement. D4 is ratified; pending activation does not mean pending ratificati
|
|
|
308
405
|
|
|
309
406
|
## Entry points
|
|
310
407
|
|
|
311
|
-
|
|
408
|
+
Bins: `right-release` (`cli/right-release.mjs`) and `rightkit-gate` (`bin/rightkit-gate.mjs`). The CLI is the supported surface.
|
|
409
|
+
|
|
410
|
+
`package.json` carries an explicit `exports` map (plan item 2.10, C4-16): one `"./<file>"` entry per public file, no `"."` index, no wildcard. It lists the two bin files, `./package.json`, the modules this README documents as SDK (`./release-dependencies.mjs`, `./engine-locator.mjs`, `./pe-content.mjs`, `./dmg-build.mjs`) and the modules that product repositories and RightKit's own tooling import by deep path today. Every other file is private: `import "@rightkit/release/release.mjs"` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`, and so does `import "@rightkit/release"`. `docs/RELEASE-EXPORTS.md` in the RightKit repository is the inventory (every consumer import with file:line) and `release-exports.test.mjs` fails when the map, that inventory or a checked-out product repository disagree. A new public subpath needs an inventory row or a README SDK section in the same commit; the signing, notarization and packaging modules in the map are a shrinking baseline, not an invitation. A file read by path (`node_modules/@rightkit/release/<file>`) is outside the map and is listed in the inventory too.
|
|
312
411
|
|
|
313
412
|
## Dependencies
|
|
314
413
|
|
|
315
414
|
| Dependency | Range | Note |
|
|
316
415
|
|---|---|---|
|
|
416
|
+
| `@rightkit/outdated` | `0.2.0` (exact) | The updater behind `right-release update` and the stale-pin refusals (plan item 2.10, C4-17). Kept in step with the workspace version of `packages/outdated` by `rightkit-registry-resolver.test.mjs` |
|
|
317
417
|
| `smol-toml` | `1.9.1` (exact) | Used to parse lock and manifest files |
|
|
318
418
|
|
|
319
419
|
Peer dependencies: none.
|
|
@@ -537,7 +637,7 @@ At build admission the controller resolves the app's EXACT locked RightKit versi
|
|
|
537
637
|
What the digest comparison is: the lock digest and the registry digest agree (metadata agreement). It is NOT proof of the bytes a build consumes; this tool does not materialize or hash the installed packages, so no byte-level claim is made.
|
|
538
638
|
|
|
539
639
|
- **Coverage.** Every RightKit dependency a manifest declares (`Cargo.toml`, `package.json`, including the directory of every canonical lock outside the app root) needs a lock entry for that package name somewhere in the canonical lock inventory; a missing or incomplete lock refuses admission, with no bundled-only fallback. This is a name-level check, not importer-specific lock proof. Lock inputs come from the build's canonical input inventory (`requiredInputs`, `pinnedCargoLocks`, native-iOS locks), not a separately built list.
|
|
540
|
-
- **Freshness is a build-admission gate.** A fresh `right-release build` reads each package's latest live (crates.io `max_stable_version`, npm `dist-tags.latest`) and REFUSES every pin behind it (`pinned X -> latest Y: run right-update run
|
|
640
|
+
- **Freshness is a build-admission gate.** A fresh `right-release build` reads each package's latest live (crates.io `max_stable_version`, npm `dist-tags.latest`) and REFUSES every pin behind it (`pinned X -> latest Y: run \`pnpm exec right-release update run\` ...`), held or not: a `.rightkit/update.json` hold is a migration lane task, reported with the refusal, never an exception. If latest cannot be read live the failure is RECOVERABLE (a cached latest is only last-observed). The latest row is always the live registry state. The cache keeps a separate high-water mark as downgrade protection for derived answers only: a lower crates.io answer while the higher version is still published is a recoverable `eligibility-conflict`; a withdrawn high-water version resolves itself; an npm `latest` tag moved lower on purpose is followed.
|
|
541
641
|
- **Reuse is trusted-local, not authenticated.** The admitted snapshot (`<state root>/rightkit-registry-snapshot.json`), the admission record (`rightkit-admission.json`: release id, commit, platform, snapshot digest, hash of `release-inputs.lock.json`) and the lock payload are plain files in the release state root. The release worker (`RIGHT_RELEASE_RIGHTKIT_SNAPSHOT`), `--resume-from`, the seal and `upload` check that they agree with each other, with this release's identity and with the current locks, and refuse a missing, foreign, inconsistent or stale set without resolving again. That catches mix-ups and edits that leave the files inconsistent. It does NOT authenticate them: anyone who can write the state root can forge a consistent set, and a resume proves only that the recorded admission is consistent with this release and the current locks, not that the registry still agrees or that the controller wrote it. There is no signing layer, and no existing broker attestation is reused here. A publication after admission changes nothing and freshness is not re-checked.
|
|
542
642
|
- **Sealed evidence.** The sealed `release-manifest.json` carries `rightReleaseVersion`, `rightkitRegistryEvidence: 1` and `rightkitRegistry` (`digest`, `snapshot`). A manifest without the evidence is refused on every seal and upload path. The only exemption is an explicitly identified pre-0.6.0 release: its manifest records no tool version (or one below 0.6.0) and its release id is named in `RIGHT_RELEASE_LEGACY_REGISTRY_RELEASES`. A stripped seal that records 0.6.0 or later is refused whatever is set.
|
|
543
643
|
- `right-release doctor` resolves live and lists newer versions as informational; `right-release doctor --admission` applies the admission refusals.
|
|
@@ -628,7 +728,9 @@ files, base Tauri config content & app-specific default-feature restrictions rem
|
|
|
628
728
|
|
|
629
729
|
## Template rename, PE digest, icon packing
|
|
630
730
|
|
|
631
|
-
`right-release init --name <Product Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run]` renames the desktop app template identity in place (see `init-command.mjs`); it runs no gate or build.
|
|
731
|
+
`right-release init --name <Product Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run]` renames the desktop app template identity in place (see `init-command.mjs`); it runs no gate or build. Besides the text files it renames `Cargo.lock` and re-sorts it the way cargo writes it (so `cargo --locked` accepts the renamed lock), recomputes the digests in `legal/legal-manifest.json` for the documents the rename changed (a changed material document means `legal-snapshot` must run again), keeps the `r2-rightapps` distribution service name, and prints the steps only a person can do: `pnpm install`, the doctor, the legal host and text, and registering the new repository on each host (`ownership-repositories.json` and its byte copy under `packages/build-control-policy`, then `right-release ownership trust-refresh` on the Mac and the Dell, which needs a person with administrator rights).
|
|
732
|
+
|
|
733
|
+
The release config is `right-release.config.json` (M24, `release-config.mjs`): every command that reads one defaults to it when it sits beside the working directory, loads a `.json` path through the strict loader, and still imports a legacy `right-release.config.mjs` for an app that has not moved. `{{localAppData}}` in a JSON config is `%LOCALAPPDATA%` on Windows and `<home>/AppData/Local` elsewhere.
|
|
632
734
|
|
|
633
735
|
`right-release pe digest <file> [--algorithm sha256|sha384|sha512] [--json]` prints the signature-independent PE content digest (Authenticode image hash: CheckSum, certificate table entry and certificate data excluded, so signing, re-signing and timestamping do not change it). `right-release pe imports <file> [--allow dll,...] [--allow-file <path>] [--json]` lists regular and delay-load imports and exits 1 when any is outside the allow list (case-insensitive; a trailing `*` allows a prefix such as `api-ms-win-*`; no allow list refuses every import). SDK: `peContentDigest(bytes)` and `verifyImportClosure(bytes, allowedDlls)` in `pe-content.mjs`.
|
|
634
736
|
|
|
@@ -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
|
+
]);
|