@rightkit/release 0.6.5 → 0.6.7
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 +196 -11
- package/admission-files.mjs +3 -2
- package/advisory-db.json +4 -0
- package/advisory-db.mjs +124 -0
- package/advisory-exceptions.mjs +13 -10
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +702 -26
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-agent-hook.mjs +9 -0
- package/bin/rightkit-gate.mjs +671 -133
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +20 -12
- 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 +37 -7
- package/command-spec.mjs +4 -5
- package/compile-inputs.mjs +66 -0
- package/consumer-contracts.json +1044 -0
- package/consumer-contracts.mjs +627 -0
- package/consumer-input-contracts.mjs +422 -0
- package/consumer-policy.mjs +588 -0
- package/consumer-source-contracts.mjs +469 -0
- package/deny-default.toml +42 -0
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +513 -54
- package/development.mjs +5 -5
- package/disk-command.mjs +270 -30
- package/disk-policy.json +50 -10
- package/disk-policy.mjs +482 -35
- package/disk-report.mjs +129 -0
- package/door-policy.mjs +228 -0
- package/gate-executor.mjs +30 -0
- package/gate-stages.mjs +110 -0
- package/github-release.mjs +5 -3
- package/github-updater-release.mjs +48 -8
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +175 -8
- package/ios-upload.mjs +4 -4
- package/legion-semantic-contracts.mjs +428 -0
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -0
- package/ownership-class-rules.mjs +277 -0
- package/ownership-key-authority.mjs +3 -1
- package/ownership-mandatory-set.mjs +3 -2
- package/package.json +46 -3
- package/pipeline-admission.mjs +12 -0
- package/publish-cargo.mjs +219 -4
- package/publish-update.mjs +19 -14
- package/release-config.mjs +87 -11
- 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 +4 -4
- package/rightkit-registry-resolver.mjs +5 -2
- package/rightkit-versions.json +178 -170
- package/shell-contract-bridge.json +251 -0
- package/shell-contract.mjs +1069 -0
- package/sign-updater.mjs +24 -4
- package/source-closure-rules.mjs +204 -0
- package/stage-sidecar-command.mjs +7 -6
- package/stage-sidecar.mjs +437 -114
- package/suite-api.generated.mjs +96 -6
- package/suite-trust-roots.mjs +198 -0
- package/test-command.mjs +3 -2
- package/upload-release.mjs +3 -2
- package/web-build.mjs +2 -1
package/bin/rightkit-gate.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// "local deterministic checks ... mandatory for all builds"). Runs from the pre-push hook that `rightkit-gate install-hook`
|
|
4
4
|
// writes, so it never depends on an agent remembering. Every applicable scan for the pushed change runs through the one
|
|
5
5
|
// scan module (../deterministic-scans.mjs); Rust units come from `cargo metadata` (one run per Cargo workspace), npm units from
|
|
6
|
-
// pnpm
|
|
6
|
+
// the pnpm roots' workspace files, so the layout is the repository's own. CI still compiles, lints and tests everything.
|
|
7
7
|
//
|
|
8
8
|
// Layout it covers (nothing is partly covered silently):
|
|
9
9
|
// - Rust: every tracked Cargo.toml that is a workspace root or a standalone package not inside another workspace is its own Cargo
|
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
// workspace's own Cargo.lock, and ci/gate.config.json `rust` may key its feature sets by `<cargoDir>:<crate>` or by the plain crate
|
|
13
13
|
// name. A tracked Cargo.toml that is not a member and cannot stand alone, a member that is untracked or ignored, and an untracked
|
|
14
14
|
// Cargo.toml are skipped with a note on the first lines of the run, never silently.
|
|
15
|
-
// - npm:
|
|
16
|
-
//
|
|
15
|
+
// - npm: every pnpm root (discoverPnpmRoots: the repository root, and any tracked directory with a pnpm-lock.yaml or pnpm-workspace.yaml
|
|
16
|
+
// beside a package.json, outside node_modules) has its own units: the pnpm-workspace.yaml globs over tracked package.json files; when that
|
|
17
|
+
// file is absent, has no `packages:` or declares ".", the root's package.json is a unit too. pnpm-lockfile and pnpm-audit run once per
|
|
18
|
+
// root in its directory (`pnpm-lockfile:<root>` below the repository root), and each unit gets tsc, the linter it configures (eslint
|
|
19
|
+
// or biome, by config file) and its tests like any package. A unit of a root below the repository root is named from its directory.
|
|
17
20
|
//
|
|
18
21
|
// Release evidence for a PRODUCT repository (an app): the pin's "a release qualified locally on both hosts" is the app's own
|
|
19
22
|
// `right-release` release QA run on both hosts (docs/CI.md "Release evidence for an app"). `right-release qualify cargo` qualifies
|
|
@@ -25,20 +28,31 @@
|
|
|
25
28
|
// run the gate (default base origin/main); --plan prints the scans and runs none;
|
|
26
29
|
// --no-cache (or RK_GATE_CACHE=0) reruns every scan
|
|
27
30
|
// rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
|
|
28
|
-
// the
|
|
29
|
-
//
|
|
31
|
+
// the deterministic stage: the app check (../app-check.mjs), then every class A scan
|
|
32
|
+
// (lockfiles, cargo-deny/audit, secrets, licences, manifests, workflow lint, syntax) over the
|
|
33
|
+
// whole tree of the commit, not a diff. Nothing compiles; without a broker class A uses
|
|
34
|
+
// plain cargo for metadata/deny/audit only. The RustRight broker runs this before it admits
|
|
35
|
+
// any compile. --list/--print-baseline/--only show the app check alone. Exit 0 pass,
|
|
36
|
+
// 1 findings, 2 could not run
|
|
30
37
|
// rightkit-gate install-hook [--force] write .githooks/pre-push and set core.hooksPath. core.hooksPath hides .git/hooks, so the
|
|
31
38
|
// pre-push hook runs the repository's own .git/hooks/pre-push first, then `rightkit-gate
|
|
32
39
|
// check --commit HEAD`, then the gate; every other hook present in .git/hooks gets a
|
|
33
40
|
// .githooks/<name> trampoline that runs it; the hook finds the gate binary in the root
|
|
34
|
-
// node_modules/.bin, then any workspace package's
|
|
41
|
+
// node_modules/.bin, then any workspace package's (each package that declares @rightkit/release by
|
|
42
|
+
// name when the root does not declare it), then pnpm exec, then node at a stable path inside the
|
|
43
|
+
// repository (hookGatePlan: from the declarations, never from what is installed). Where @rightkit/release is
|
|
35
44
|
// installed it also writes the advisory ownership pre-commit step (`right-release hooks install`
|
|
36
45
|
// is an alias of this command)
|
|
37
46
|
//
|
|
38
47
|
// From the pre-push hook, git's ref lines arrive on stdin: the commit being pushed must be the checked-out HEAD (broker scans
|
|
39
48
|
// read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
|
|
40
49
|
// check it out first (a detached HEAD is fine), push HEAD:<branch>, return.
|
|
41
|
-
//
|
|
50
|
+
// The executor is chosen by the gate, never the caller (gate-executor.mjs resolveExecutor): the RustRight broker where this machine
|
|
51
|
+
// has it (`rustright` on PATH, or its managed cargo shim), a refusal on a broker-managed machine whose broker is unreachable, and
|
|
52
|
+
// direct cargo on any other host (a cloud container, a CI runner). Every host runs the same gates in the same order (app rules, class A,
|
|
53
|
+
// the compile scans); an unmanaged host is never told to find a broker. The pre-push hook judges every pushed ref.
|
|
54
|
+
// ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it; `npm.<unit>` sets `quietTests` and
|
|
55
|
+
// `requiresBroker: { "reason": "..." }` (a test the package scripts do not show reaching the broker: node -e, node scripts, sh -c, pnpm --dir).
|
|
42
56
|
// Result cache: a passing class B scan is reused only when its scan id, command, tool version, environment and input digest
|
|
43
57
|
// (the gated commit's git object ids for the crate's workspace closure, the shared build inputs, ci/gate.config.json and every
|
|
44
58
|
// path the closure's sources include from outside their crate; the whole HEAD tree for Rust tests unless ci/gate.config.json
|
|
@@ -53,18 +67,22 @@ import fs from "node:fs";
|
|
|
53
67
|
import os from "node:os";
|
|
54
68
|
import path from "node:path";
|
|
55
69
|
import { fileURLToPath } from "node:url";
|
|
56
|
-
import { WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, digestScanSet, fileScanCache, metadataArgs, planScans, runScans, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
|
|
70
|
+
import { BIOME_CONFIG_FILES, ESLINT_CONFIG_FILES, WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, digestScanSet, fileScanCache, findRustfmt, metadataArgs, planScans, runScans, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
|
|
57
71
|
import { changedLockNames, parseLock, reach } from "../lock-graph.mjs";
|
|
72
|
+
import { resolveExecutor } from "../gate-executor.mjs";
|
|
73
|
+
import { bindingOf, endRun, readReceipt, RECEIPT_SCHEMA, sameBinding, sameSource, sealedFor, stagesTo, startRun, STAGES, writeReceipt } from "../gate-stages.mjs";
|
|
74
|
+
import { spawnSync as spawnInherit } from "../background-process.mjs";
|
|
58
75
|
|
|
59
76
|
const ZERO = /^0+$/;
|
|
60
|
-
const GRANT_FILE = ".rightkit-local-build.json";
|
|
61
77
|
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
62
78
|
const posix = (p) => String(p).replaceAll("\\", "/");
|
|
63
79
|
const text = (r) => String(r?.stdout ?? "");
|
|
64
80
|
const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
|
|
65
81
|
const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
|
|
66
82
|
|
|
67
|
-
|
|
83
|
+
// A green `check --only` view: app rules passed but no class A scan ran. Distinct from 0 so no caller can admit it as a full check.
|
|
84
|
+
export const PARTIAL_EXIT = 3;
|
|
85
|
+
const USAGE = `usage: rightkit-gate --to check|compile|package|qualify|release [--platform mac|win] [--tier patch|update] [--receipt-in <file>] [--receipt-out <file>] | rightkit-gate --to dev|test [-- <right-release args>] | rightkit-gate stage --platform mac|win [--tier patch|update] | rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | --version`;
|
|
68
86
|
|
|
69
87
|
// ---- pure helpers (exported for tests) ----------------------------------------------------------------------
|
|
70
88
|
|
|
@@ -115,31 +133,77 @@ const unitName = (pkgName, dir) => {
|
|
|
115
133
|
return SAFE_NAME.test(n) ? n : dir === "." ? "root" : dir.replace(/\//g, "-");
|
|
116
134
|
};
|
|
117
135
|
|
|
136
|
+
// The bounds of the pnpm root walk. A lock deeper than this is a buried fixture, not an app; more roots than this is refused, because covering
|
|
137
|
+
// some of them would hide the rest.
|
|
138
|
+
const PNPM_ROOT_MAX_DEPTH = 6;
|
|
139
|
+
const PNPM_ROOT_MAX_COUNT = 64;
|
|
140
|
+
|
|
141
|
+
/** Tracked files with exactly this name (a lister that ignores the name is filtered here). */
|
|
142
|
+
const trackedNamed = (tracked, name) => tracked(name).map(posix).filter((p) => path.posix.basename(p) === name);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The pnpm roots of the repository, sorted: "." when the repository root holds a pnpm-workspace.yaml or a package.json, and every other
|
|
146
|
+
* tracked directory (outside node_modules, at most 6 levels deep) that holds a pnpm-lock.yaml or a pnpm-workspace.yaml beside a
|
|
147
|
+
* package.json. A repository such as CodeRight (apps/coderight-tauri and apps/coderight-web, each with its own lock, nothing at the root) has
|
|
148
|
+
* two; a plain repository has "." only. A walk over the tracked files, never the file system: nothing in node_modules or untracked is a root.
|
|
149
|
+
*/
|
|
150
|
+
export function discoverPnpmRoots({ root, tracked, exists }) {
|
|
151
|
+
const manifestDirs = new Set(trackedNamed(tracked, "package.json").map((p) => path.posix.dirname(p)));
|
|
152
|
+
const roots = new Set();
|
|
153
|
+
if (exists(path.join(root, "pnpm-workspace.yaml")) || exists(path.join(root, "package.json"))) roots.add(".");
|
|
154
|
+
for (const file of [...trackedNamed(tracked, "pnpm-lock.yaml"), ...trackedNamed(tracked, "pnpm-workspace.yaml")]) {
|
|
155
|
+
const dir = path.posix.dirname(file);
|
|
156
|
+
const parts = dir.split("/");
|
|
157
|
+
if (dir === "." || parts.includes("node_modules") || parts.length > PNPM_ROOT_MAX_DEPTH || !manifestDirs.has(dir)) continue;
|
|
158
|
+
roots.add(dir);
|
|
159
|
+
}
|
|
160
|
+
if (roots.size > PNPM_ROOT_MAX_COUNT) throw new Error(`${roots.size} pnpm roots (the gate covers at most ${PNPM_ROOT_MAX_COUNT}, and covering some would hide the rest)`);
|
|
161
|
+
return [...roots].sort();
|
|
162
|
+
}
|
|
163
|
+
|
|
118
164
|
/**
|
|
119
|
-
* npm units: pnpm-workspace.yaml globs over tracked package.json files, else the root
|
|
120
|
-
* unit as well when the workspace file is absent, has no `packages:` (or an empty one) or declares
|
|
121
|
-
* `packages:` list that does not name the root it is only the workspace's own manifest and is not a unit.
|
|
165
|
+
* npm units, per pnpm root (discoverPnpmRoots). Within a root: pnpm-workspace.yaml globs over the tracked package.json files, else the root's
|
|
166
|
+
* own package.json. The root package.json is a unit as well when the workspace file is absent, has no `packages:` (or an empty one) or declares
|
|
167
|
+
* "." (or "./"); with a `packages:` list that does not name the root it is only the workspace's own manifest and is not a unit. A directory
|
|
168
|
+
* belongs to the deepest root that holds it. The repository root's units are named from their package names (`@scope/x` -> `scope-x`, a
|
|
169
|
+
* collision falls back to the directory) and carry no `root`, exactly as in a repository with one root. The units of any other root are named
|
|
170
|
+
* from their directory (`apps/coderight-web` -> `apps-coderight-web`), so their scan ids carry the root path, and carry `root`.
|
|
122
171
|
*/
|
|
123
172
|
export function discoverNpmUnits({ root, tracked, readFile, exists }) {
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
173
|
+
const roots = discoverPnpmRoots({ root, tracked, exists });
|
|
174
|
+
const manifestDirs = [...new Set(trackedNamed(tracked, "package.json").map((p) => path.posix.dirname(p)))];
|
|
175
|
+
const within = (rootDir, dir) => (rootDir === "." ? dir : dir === rootDir ? "." : dir.startsWith(`${rootDir}/`) ? dir.slice(rootDir.length + 1) : null);
|
|
176
|
+
const joined = (rootDir, rel) => (rootDir === "." ? rel : rel === "." ? rootDir : `${rootDir}/${rel}`);
|
|
177
|
+
const ownerOf = (dir) => roots.reduce((best, r) => (within(r, dir) !== null && (best === null || r.length > best.length) ? r : best), null);
|
|
178
|
+
const found = new Map();
|
|
179
|
+
for (const rootDir of roots) {
|
|
180
|
+
let dirs;
|
|
181
|
+
const workspaceFile = path.join(root, joined(rootDir, "pnpm-workspace.yaml"));
|
|
182
|
+
if (exists(workspaceFile)) {
|
|
183
|
+
const globs = parseWorkspaceGlobs(readFile(workspaceFile));
|
|
184
|
+
const inc = globs.filter((g) => !g.startsWith("!")).map(globToRegExp);
|
|
185
|
+
const exc = globs.filter((g) => g.startsWith("!")).map((g) => globToRegExp(g.slice(1)));
|
|
186
|
+
const positive = globs.filter((g) => !g.startsWith("!"));
|
|
187
|
+
const rootDeclared = positive.length === 0 || positive.some((g) => g === "." || g === "./");
|
|
188
|
+
dirs = manifestDirs.map((d) => within(rootDir, d)).filter((rel) => rel !== null && (rel === "." ? rootDeclared : inc.some((r) => r.test(rel))) && !exc.some((r) => r.test(rel))).map((rel) => joined(rootDir, rel));
|
|
189
|
+
} else dirs = exists(path.join(root, joined(rootDir, "package.json"))) ? [rootDir] : [];
|
|
190
|
+
for (const dir of dirs) if (ownerOf(dir) === rootDir) found.set(dir, rootDir);
|
|
191
|
+
}
|
|
192
|
+
const rows = [...found.keys()].sort().map((dir) => {
|
|
136
193
|
let manifest;
|
|
137
194
|
try { manifest = JSON.parse(readFile(path.join(root, dir, "package.json")).replace(/^/, "")); } catch (e) { throw new Error(`${dir}/package.json: ${e.message}`); }
|
|
138
|
-
|
|
195
|
+
const rootDir = found.get(dir);
|
|
196
|
+
return { dir, root: rootDir, name: rootDir === "." ? unitName(manifest.name, dir) : dir.replace(/\//g, "-") };
|
|
139
197
|
});
|
|
140
198
|
const count = new Map();
|
|
141
199
|
for (const r of rows) count.set(r.name, (count.get(r.name) ?? 0) + 1);
|
|
142
|
-
|
|
200
|
+
const named = rows.map((r) => ({ ...r, name: count.get(r.name) > 1 && r.root === "." ? unitName("", r.dir === "." ? "root" : r.dir) : r.name }));
|
|
201
|
+
const taken = new Set();
|
|
202
|
+
for (const r of named) {
|
|
203
|
+
if (taken.has(r.name)) throw new Error(`two npm units are named ${r.name} (${r.dir}); rename one of the packages`);
|
|
204
|
+
taken.add(r.name);
|
|
205
|
+
}
|
|
206
|
+
return named.map((r) => ({ name: r.name, dir: r.dir, ...(r.root === "." ? {} : { root: r.root }) }));
|
|
143
207
|
}
|
|
144
208
|
|
|
145
209
|
const dirOfManifest = (m) => path.posix.dirname(m);
|
|
@@ -205,13 +269,56 @@ export function manifestTables(text) {
|
|
|
205
269
|
return tables;
|
|
206
270
|
}
|
|
207
271
|
|
|
272
|
+
/**
|
|
273
|
+
* The value text of `key` in the top-level table `table` of a manifest read by manifestTables (everything from the `=` on), whether it is
|
|
274
|
+
* written under `[table]` or as the dotted root key `table.key = ...`; null when absent. No value parsing: tomlString and tomlStrings read it.
|
|
275
|
+
*/
|
|
276
|
+
export function tomlValue(tables, table, key) {
|
|
277
|
+
const esc = (x) => x.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
278
|
+
const own = tables.get(table) ?? "";
|
|
279
|
+
const inTable = new RegExp(`^[ \\t]*${esc(key)}[ \\t]*=[ \\t]*`, "m").exec(own);
|
|
280
|
+
if (inTable) return own.slice(inTable.index + inTable[0].length);
|
|
281
|
+
const root = tables.get("preamble") ?? "";
|
|
282
|
+
const dotted = new RegExp(`^[ \\t]*${esc(table)}\\.${esc(key)}[ \\t]*=[ \\t]*`, "m").exec(root);
|
|
283
|
+
return dotted ? root.slice(dotted.index + dotted[0].length) : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** True when the manifest has the top-level table `name`: as `[name]`, as a `[name.sub]` table, or as dotted root keys `name.key = ...`. */
|
|
287
|
+
export const hasTomlTable = (tables, name) => tables.has(name) || [...tables.keys()].some((k) => k.startsWith(`${name}.`)) || new RegExp(`^[ \\t]*${name.replace(/\W/g, "\\$&")}\\.`, "m").test(tables.get("preamble") ?? "");
|
|
288
|
+
|
|
289
|
+
/** The string a TOML value starts with ("basic" or 'literal'); null when the value is not a string. */
|
|
290
|
+
export function tomlString(value) {
|
|
291
|
+
const m = /^(?:"((?:[^"\\\n]|\\.)*)"|'([^'\n]*)')/.exec(String(value ?? ""));
|
|
292
|
+
return m ? (m[1] !== undefined ? m[1].replace(/\\(.)/g, "$1") : m[2]) : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The strings of the TOML array a value starts with: "basic" and 'literal' strings, across lines, with `#` comments (and any `]` in
|
|
297
|
+
* them or in a string) skipped. [] when the value is not an array.
|
|
298
|
+
*/
|
|
299
|
+
export function tomlStrings(value) {
|
|
300
|
+
const s = String(value ?? "");
|
|
301
|
+
if (s[0] !== "[") return [];
|
|
302
|
+
const out = [];
|
|
303
|
+
for (let i = 1; i < s.length;) {
|
|
304
|
+
const c = s[i];
|
|
305
|
+
if (c === "]") break;
|
|
306
|
+
if (c === "#") { while (i < s.length && s[i] !== "\n") i += 1; continue; }
|
|
307
|
+
if (c === '"' || c === "'") {
|
|
308
|
+
let j = i + 1; let v = "";
|
|
309
|
+
while (j < s.length && s[j] !== c && s[j] !== "\n") { if (c === '"' && s[j] === "\\" && j + 1 < s.length) { v += s[j + 1]; j += 2; } else { v += s[j]; j += 1; } }
|
|
310
|
+
out.push(v); i = j + 1; continue;
|
|
311
|
+
}
|
|
312
|
+
i += 1;
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
208
317
|
/** The repository-relative directories a workspace manifest `exclude`s (`[workspace] exclude = [...]`); [] when none or unreadable. */
|
|
209
318
|
export function workspaceExcludes(readFile, root, cargoDir) {
|
|
210
319
|
let text;
|
|
211
320
|
try { text = readFile(path.join(root, cargoDir === "." ? "Cargo.toml" : `${cargoDir}/Cargo.toml`)); } catch { return []; }
|
|
212
|
-
|
|
213
|
-
const list = /^\s*exclude\s*=\s*\[([\s\S]*?)\]/m.exec(body)?.[1] ?? "";
|
|
214
|
-
return [...list.matchAll(/"([^"]+)"/g)].map((m) => posix(m[1]).replace(/\/+$/, "")).map((d) => (cargoDir === "." ? d : `${cargoDir}/${d}`));
|
|
321
|
+
return tomlStrings(tomlValue(manifestTables(text), "workspace", "exclude")).map((d) => posix(d).replace(/\/+$/, "")).map((d) => (cargoDir === "." ? d : `${cargoDir}/${d}`));
|
|
215
322
|
}
|
|
216
323
|
|
|
217
324
|
// The manifest tables whose every effect on a build is also written into Cargo.lock: a changed source ([patch.*]) and workspace
|
|
@@ -254,8 +361,10 @@ export function manifestChangeIsLockReflected(before, after) {
|
|
|
254
361
|
* A member whose Cargo.toml is not tracked (untracked or ignored) is dropped into `skipped`, as are `untracked()` manifests.
|
|
255
362
|
* Returns { present, workspaces: [{ cargoDir, units }], units, cargoDir, skipped: [{ manifest, why }] }; `units` is every workspace's
|
|
256
363
|
* [{ name, crate, dir, publish, cargoDir }] and `cargoDir` the first workspace's (the repository root's when it has one).
|
|
364
|
+
* `metadata(dir)` (optional) replaces the cargo call with a reader of the same { cargoDir, members } shape (extra fields are ignored): the
|
|
365
|
+
* deferred pre-push gate passes manifestWorkspaceMetadata where no broker can run cargo.
|
|
257
366
|
*/
|
|
258
|
-
export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8") }) {
|
|
367
|
+
export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8"), metadata = null }) {
|
|
259
368
|
const manifests = tracked("Cargo.toml");
|
|
260
369
|
const trackedSet = new Set(manifests);
|
|
261
370
|
const locks = new Set(tracked("Cargo.lock"));
|
|
@@ -283,7 +392,7 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
|
|
|
283
392
|
if (!locks.has(`${dir}/Cargo.lock`)) { skipped.push({ manifest: m, why: `below the Cargo workspace ${parent.cargoDir} with no committed Cargo.lock, so its build is not reproducible; gated through the consumer rows of the crate it exercises` }); continue; }
|
|
284
393
|
}
|
|
285
394
|
let found;
|
|
286
|
-
try { found = workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
|
|
395
|
+
try { found = metadata ? metadata(dir) : workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
|
|
287
396
|
if (!parent) throw e;
|
|
288
397
|
skipped.push({ manifest: m, why: `below the Cargo workspace ${parent.cargoDir} but not one of its members, and cargo refuses it on its own (${String(e.message).replace(/^cargo metadata failed: /, "").replace(/ \(in [^)]*\)$/, "")})` });
|
|
289
398
|
continue;
|
|
@@ -306,6 +415,27 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
|
|
|
306
415
|
return { present: true, workspaces, units, cargoDir: workspaces[0]?.cargoDir ?? ".", skipped };
|
|
307
416
|
}
|
|
308
417
|
|
|
418
|
+
// ---- pre-push input and lock scope ------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* git's pre-push stdin: one "<local ref> <local sha> <remote ref> <remote sha>" per line, the four fields joined by single ASCII spaces.
|
|
422
|
+
* Git forbids an ASCII space in a ref name but allows Unicode whitespace (U+00A0, U+2003, U+3000, ...), so the split is on " " alone: a
|
|
423
|
+
* whitespace split would cut such a ref into extra fields and the line would vanish. Blank lines are skipped; any other line that is not
|
|
424
|
+
* four non-empty fields is returned in `malformed` ({ number, line }), never dropped. Returns { lines: [{ localRef, localSha, remoteRef,
|
|
425
|
+
* remoteSha }], malformed }.
|
|
426
|
+
*/
|
|
427
|
+
export function parsePushLines(stdin) {
|
|
428
|
+
const lines = []; const malformed = [];
|
|
429
|
+
String(stdin ?? "").split("\n").forEach((raw, index) => {
|
|
430
|
+
const line = raw.replace(/\r$/, "");
|
|
431
|
+
if (line === "") return;
|
|
432
|
+
const f = line.split(" ");
|
|
433
|
+
if (f.length !== 4 || f.some((x) => x === "")) { malformed.push({ number: index + 1, line }); return; }
|
|
434
|
+
lines.push({ localRef: f[0], localSha: f[1], remoteRef: f[2], remoteSha: f[3] });
|
|
435
|
+
});
|
|
436
|
+
return { lines, malformed };
|
|
437
|
+
}
|
|
438
|
+
|
|
309
439
|
/**
|
|
310
440
|
* The crates a lock-reflected workspace change reaches (the lock graph CI's selection uses): a Cargo.lock change, a vendor/ change
|
|
311
441
|
* (the vendored crate is a lock package), and a workspace manifest change confined to the tables the lock also records
|
|
@@ -351,8 +481,18 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
|
|
|
351
481
|
* gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three levels down: tauri-app-next,
|
|
352
482
|
* apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when pnpm is absent; the line it
|
|
353
483
|
* chose is printed once. No absolute path is written: everything is resolved from git at run time.
|
|
484
|
+
*
|
|
485
|
+
* `packages` (default none) is the multi-root form: the repository-relative directories of the packages that declare @rightkit/release when the
|
|
486
|
+
* repository root does not declare it (CutRight's apps/cutright, HeardRight's tauri-app-next). Each one's node_modules/.bin/rightkit-gate is tried
|
|
487
|
+
* right after the root's (the glob above stops three levels down), and the pnpm fallback runs in the first of them (`pnpm --dir <package> exec`;
|
|
488
|
+
* pnpm at the root of such a repository has no rightkit-gate to run). With none, the text is exactly what 0.6.4 and 0.6.5 wrote
|
|
489
|
+
* (fixtures/published-gate-hooks.json holds its digest, and a repository whose root installs the package must keep getting it, byte for byte,
|
|
490
|
+
* or the committed hook of every app copied from the template stops being "already current"). `nodePath` and every package are written into
|
|
491
|
+
* a shell script: installHook passes only plain relative paths (hookGatePlan).
|
|
354
492
|
*/
|
|
355
|
-
export function hookText(nodePath) {
|
|
493
|
+
export function hookText(nodePath, packages = []) {
|
|
494
|
+
const bins = packages.map((dir) => ` ${dir}/node_modules/.bin/rightkit-gate`).join("");
|
|
495
|
+
const pnpmExec = packages.length ? `pnpm --dir ${packages[0]} exec rightkit-gate` : "pnpm exec rightkit-gate";
|
|
356
496
|
return `#!/bin/sh
|
|
357
497
|
# Mandatory local check and gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
|
|
358
498
|
cd "$(git rev-parse --show-toplevel)" || exit 1
|
|
@@ -365,17 +505,17 @@ if [ -x "$own" ]; then
|
|
|
365
505
|
echo "rightkit-gate: running $own" >&2
|
|
366
506
|
"$own" "$@" < "$stdin" || exit $?
|
|
367
507
|
fi
|
|
368
|
-
# The gate binary: the root install, any workspace package's install, then pnpm exec.
|
|
508
|
+
# The gate binary: the root install, ${packages.length ? "the install of each package that declares @rightkit/release, any other workspace package's install, then pnpm exec in the first of them" : "any workspace package's install, then pnpm exec"}.
|
|
369
509
|
found=""
|
|
370
|
-
for bin in node_modules/.bin/rightkit-gate */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
|
|
510
|
+
for bin in node_modules/.bin/rightkit-gate${bins} */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
|
|
371
511
|
if [ -x "$bin" ]; then found="$bin"; break; fi
|
|
372
512
|
done
|
|
373
513
|
if [ -n "$found" ]; then
|
|
374
514
|
echo "rightkit-gate: using $found" >&2
|
|
375
515
|
rightkit_gate() { "$found" "$@"; }
|
|
376
516
|
elif command -v pnpm >/dev/null 2>&1; then
|
|
377
|
-
echo "rightkit-gate: using
|
|
378
|
-
rightkit_gate() {
|
|
517
|
+
echo "rightkit-gate: using ${pnpmExec}" >&2
|
|
518
|
+
rightkit_gate() { ${pnpmExec} "$@"; }
|
|
379
519
|
else
|
|
380
520
|
echo "rightkit-gate: using node ${nodePath}" >&2
|
|
381
521
|
rightkit_gate() { node "${nodePath}" "$@"; }
|
|
@@ -405,7 +545,6 @@ exit 0
|
|
|
405
545
|
// names are chained; anything else is reported and skipped, never interpolated.
|
|
406
546
|
const HOOK_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
407
547
|
const chainableHook = (name) => !name.endsWith(".sample") && HOOK_FILE_NAME.test(name);
|
|
408
|
-
|
|
409
548
|
// ---- result cache inputs ------------------------------------------------------------------------------------
|
|
410
549
|
|
|
411
550
|
// The build inputs that change how every crate compiles, relative to the Cargo workspace root, plus the gate's own config.
|
|
@@ -568,7 +707,7 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
|
|
|
568
707
|
* when declared. Without a declaration, a closure source that navigates out of its crate at run time without a literal path
|
|
569
708
|
* (externalInputs `leavesCrate`: workspace root, current_dir, a bare "..", the manifest directory's parent) keys the scan on the
|
|
570
709
|
* whole HEAD tree, since its tests may read anything.
|
|
571
|
-
* - An npm package scan (test:npm, tsc, eslint) keys on the package's workspace closure (its `workspace:` dependencies,
|
|
710
|
+
* - An npm package scan (test:npm, tsc, eslint, biome) keys on the package's workspace closure (its `workspace:` dependencies,
|
|
572
711
|
* transitively, by package name) and the root npm inputs; test:scripts-ci on scripts/, ci/, .github/, packages/release and the
|
|
573
712
|
* root install graph. Every other class B scan (generated-docs, swift-build) uses the whole tree.
|
|
574
713
|
* - Class A: cargo-deny, cargo-audit, pnpm-audit, pnpm-lockfile and advisory-exceptions are never cached (live answers); rustfmt,
|
|
@@ -577,6 +716,9 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
|
|
|
577
716
|
*/
|
|
578
717
|
// Root npm inputs every package scan reads: the install graph and the shared tool configuration.
|
|
579
718
|
const SHARED_NPM_INPUTS = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", "tsconfig.json", "tsconfig.base.json", "eslint.config.js", "eslint.config.mjs", ".eslintrc.json", "biome.json", "vitest.config.ts", "vitest.config.mjs", "vitest.workspace.ts"];
|
|
719
|
+
// ESLint and Biome look their configuration up from the package directory through every ancestor. A config in an ancestor directory (or of another
|
|
720
|
+
// name than the shared list above) is part of a package's key only when the commit has it, so a repository without one keeps the key it had.
|
|
721
|
+
const LINT_CONFIG_NAMES = [...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
|
|
580
722
|
// The gate's own test suite reads the CI scripts, the gate config and the release package it imports from.
|
|
581
723
|
const SCRIPTS_CI_INPUTS = ["scripts", "ci", ".github", "packages/release", "package.json", "pnpm-lock.yaml"];
|
|
582
724
|
|
|
@@ -597,7 +739,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
597
739
|
};
|
|
598
740
|
let npmByPkgName = null;
|
|
599
741
|
const npmClosure = (unit) => {
|
|
600
|
-
if (npmByPkgName === null) { npmByPkgName = new Map(); for (const u of npmUnits) { const m = npmManifest(u); if (m?.name) npmByPkgName.set(m.name
|
|
742
|
+
if (npmByPkgName === null) { npmByPkgName = new Map(); for (const u of npmUnits) { const m = npmManifest(u); if (m?.name) npmByPkgName.set(`${u.root ?? "."}\0${m.name}`, u); } }
|
|
601
743
|
const seen = new Map([[unit.name, unit]]); const stack = [unit]; let unresolved = false;
|
|
602
744
|
while (stack.length) {
|
|
603
745
|
const u = stack.pop(); const m = npmManifest(u);
|
|
@@ -605,7 +747,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
605
747
|
for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
|
|
606
748
|
for (const [name, spec] of Object.entries(m[section] ?? {})) {
|
|
607
749
|
if (!/^(workspace:|link:|file:)/.test(String(spec))) continue;
|
|
608
|
-
const dep = npmByPkgName.get(name);
|
|
750
|
+
const dep = npmByPkgName.get(`${u.root ?? "."}\0${name}`);
|
|
609
751
|
if (!dep) { unresolved = true; continue; }
|
|
610
752
|
if (!seen.has(dep.name)) { seen.set(dep.name, dep); stack.push(dep); }
|
|
611
753
|
}
|
|
@@ -613,6 +755,18 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
613
755
|
}
|
|
614
756
|
return { members: [...seen.values()].sort((a, b) => (a.name < b.name ? -1 : 1)), unresolved };
|
|
615
757
|
};
|
|
758
|
+
// The npm inputs shared by a set of units: the repository root's (always, null when absent: the key a one-root repository has always had),
|
|
759
|
+
// the same files of every other pnpm root the units belong to, and the linter configuration of their ancestor directories that the commit has.
|
|
760
|
+
const sharedNpmInputs = (members) => {
|
|
761
|
+
const required = new Set(SHARED_NPM_INPUTS); const optional = new Set();
|
|
762
|
+
for (const m of members) {
|
|
763
|
+
if ((m.root ?? ".") !== ".") for (const p of SHARED_NPM_INPUTS) required.add(inWs(m.root, p));
|
|
764
|
+
for (const dir of ancestorDirsOf(m.dir)) if (dir !== m.dir) for (const f of LINT_CONFIG_NAMES) optional.add(inWs(dir, f));
|
|
765
|
+
}
|
|
766
|
+
const nested = [...required].filter((p) => !SHARED_NPM_INPUTS.includes(p)).sort();
|
|
767
|
+
const extra = [...optional].filter((p) => !required.has(p)).sort();
|
|
768
|
+
return [...SHARED_NPM_INPUTS, ...nested].map((p) => [p, idOf(p)]).concat(extra.map((p) => [p, idOf(p)]).filter(([, id]) => id !== null && id !== undefined));
|
|
769
|
+
};
|
|
616
770
|
// A unit belongs to the Cargo workspace it names (rustUnits[].cargoDir, else the global one) and is a package of that workspace's
|
|
617
771
|
// own lock under its crate name (rustUnits[].crate, else its name); its closure never crosses into another workspace.
|
|
618
772
|
const wsOf = (u) => u.cargoDir ?? cargoDir;
|
|
@@ -667,11 +821,11 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
667
821
|
if (!unit) {
|
|
668
822
|
if (typeof readBlob !== "function") return whole();
|
|
669
823
|
// npm package scans: the package's workspace closure plus the root npm inputs.
|
|
670
|
-
const npmMatch = /^(test:npm|tsc|eslint):(.+)$/.exec(scan.id);
|
|
824
|
+
const npmMatch = /^(test:npm|tsc|eslint|biome):(.+)$/.exec(scan.id);
|
|
671
825
|
if (npmMatch && npmByUnit.has(npmMatch[2])) {
|
|
672
826
|
const { members, unresolved } = npmClosure(npmByUnit.get(npmMatch[2]));
|
|
673
827
|
if (unresolved) return whole();
|
|
674
|
-
return canonicalJson({ kind: "npm", id: scan.id, members: members.map((m) => [m.name, m.dir, idOf(m.dir)]), shared:
|
|
828
|
+
return canonicalJson({ kind: "npm", id: scan.id, members: members.map((m) => [m.name, m.dir, idOf(m.dir)]), shared: sharedNpmInputs(members) });
|
|
675
829
|
}
|
|
676
830
|
if (scan.id === "test:scripts-ci") return canonicalJson({ kind: "dirs", id: scan.id, inputs: SCRIPTS_CI_INPUTS.map((p) => [p, idOf(p)]) });
|
|
677
831
|
return whole();
|
|
@@ -765,36 +919,67 @@ export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-g
|
|
|
765
919
|
};
|
|
766
920
|
}
|
|
767
921
|
|
|
768
|
-
// ---- command resolution -------------------------------------------------------------------------------------
|
|
769
922
|
|
|
770
|
-
// Cargo only through the broker: `rustright cargo`, or a `cargo` on PATH that is the managed shim (it runs cargo-guard.mjs).
|
|
771
|
-
function resolveCargoCommand(o) {
|
|
772
|
-
const which = (cmd) => {
|
|
773
|
-
const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]);
|
|
774
|
-
return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
775
|
-
};
|
|
776
|
-
if (which("rustright").length) return ["rustright", "cargo"];
|
|
777
|
-
const first = which("cargo")[0];
|
|
778
|
-
if (first) { try { if (o.readFile(first).includes("cargo-guard.mjs")) return ["cargo"]; } catch { /* unreadable: not the shim */ } }
|
|
779
|
-
return null;
|
|
780
|
-
}
|
|
781
923
|
|
|
782
924
|
// ---- install-hook -------------------------------------------------------------------------------------------
|
|
783
925
|
|
|
784
|
-
/**
|
|
785
|
-
|
|
926
|
+
/**
|
|
927
|
+
* The tracked package.json files (the root one when git cannot list) that matter to the hook: `provider` is the first that declares
|
|
928
|
+
* @rightkit/release or is that package itself, `declaring` the directories of every one that declares it ("." for the root; the package itself is
|
|
929
|
+
* not one: it installs nothing of itself).
|
|
930
|
+
*/
|
|
931
|
+
function releasePackages(o, root, git) {
|
|
786
932
|
let files = ["package.json"];
|
|
787
933
|
const listed = git("ls-files", "-z", "--", ":(glob)**/package.json");
|
|
788
934
|
if (!failed(listed)) files = text(listed).split("\0").filter(Boolean).map(posix);
|
|
935
|
+
let provider = null; const declaring = [];
|
|
789
936
|
for (const file of files) {
|
|
790
937
|
const abs = path.join(root, file);
|
|
791
938
|
if (!o.exists(abs)) continue;
|
|
792
939
|
let manifest;
|
|
793
940
|
try { manifest = JSON.parse(o.readFile(abs).replace(/^\uFEFF/, "")); } catch { continue; }
|
|
794
|
-
if (manifest?.name === "@rightkit/release")
|
|
795
|
-
|
|
941
|
+
if (manifest?.name === "@rightkit/release") { provider ??= file; continue; }
|
|
942
|
+
if (["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"].some((section) => manifest?.[section] && Object.hasOwn(manifest[section], "@rightkit/release"))) {
|
|
943
|
+
provider ??= file;
|
|
944
|
+
declaring.push(path.posix.dirname(file));
|
|
945
|
+
}
|
|
796
946
|
}
|
|
797
|
-
return
|
|
947
|
+
return { provider, declaring: [...new Set(declaring)].sort() };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Where a package installs the gate, below its own directory. pnpm links it there for a package that declares @rightkit/release, so the path is the
|
|
951
|
+
// same for every version (node_modules/.pnpm/@rightkit+release@x.y.z/... is the store copy it points at, and goes stale with each bump).
|
|
952
|
+
const GATE_IN_PACKAGE = "node_modules/@rightkit/release/bin/rightkit-gate.mjs";
|
|
953
|
+
// What may be written into the committed shell script: a plain relative path of letters, digits and _ - . @ + /, no `..`, no `.` segment, no
|
|
954
|
+
// leading dash (it would read as an option of `pnpm --dir`).
|
|
955
|
+
const HOOK_PATH_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9_@.+-]+$/;
|
|
956
|
+
export const hookPathIsPlain = (value) => typeof value === "string" && value !== "" && !value.startsWith("-") && value.split("/").every((segment) => HOOK_PATH_SEGMENT.test(segment));
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* What the pre-push hook is told about where the gate lives: { nodePath, packages, unsafe }. `nodePath` is the last resort the hook runs with
|
|
960
|
+
* node, `packages` the extra directories hookText searches (multi-root form), `unsafe` the declaring packages whose directory cannot be written
|
|
961
|
+
* into a shell script. `declaring` is releasePackages(...).declaring; `gateFile` is where this gate runs from.
|
|
962
|
+
*
|
|
963
|
+
* A function of what the repository's package.json files declare, never of what is installed: the same tree gives the same hook on a fresh
|
|
964
|
+
* clone, an exports checkout, after `pnpm install` and after a version bump, so `install-hook` (a `prepare` script) never meets a hook it
|
|
965
|
+
* wrote earlier as one that "already exists and differs". It takes no `exists`, so that cannot creep back in. Always a stable,
|
|
966
|
+
* version-independent path relative to the repository, never one outside it, absolute, or inside the pnpm store:
|
|
967
|
+
* 1. The root declares @rightkit/release (whatever else does): its link and no extra packages, the hook 0.6.4 and 0.6.5 wrote (Pulse declares
|
|
968
|
+
* it at the root and in hub/; RightKit itself at the root).
|
|
969
|
+
* 2. Only packages declare it (CutRight's apps/cutright, HeardRight's tauri-app-next): the link of the first of them in sorted order, whichever is
|
|
970
|
+
* installed, and every one of them is searched.
|
|
971
|
+
* 3. No package.json declares it (a checkout of the package itself that nothing depends on), or only under a directory the hook cannot name: if
|
|
972
|
+
* this gate runs from inside the repository and not from a node_modules, its path from the root, else the root's link, never a path
|
|
973
|
+
* outside the repository. This is the one case that reads where the gate runs from, because the repository says nothing.
|
|
974
|
+
*/
|
|
975
|
+
export function hookGatePlan({ root, gateFile, declaring }) {
|
|
976
|
+
const plain = declaring.filter((d) => d !== "." && hookPathIsPlain(d));
|
|
977
|
+
const unsafe = declaring.filter((d) => d !== "." && !hookPathIsPlain(d));
|
|
978
|
+
if (declaring.includes(".")) return { nodePath: GATE_IN_PACKAGE, packages: [], unsafe };
|
|
979
|
+
if (plain.length) return { nodePath: `${plain[0]}/${GATE_IN_PACKAGE}`, packages: plain, unsafe };
|
|
980
|
+
const relative = posix(path.relative(root, gateFile));
|
|
981
|
+
const inside = relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) && !relative.split("/").includes("node_modules") && hookPathIsPlain(relative);
|
|
982
|
+
return { nodePath: inside ? relative : GATE_IN_PACKAGE, packages: [], unsafe };
|
|
798
983
|
}
|
|
799
984
|
|
|
800
985
|
// The advisory ownership pre-commit step (`right-release hooks install`, ownership-hooks.mjs) runs the pinned @rightkit/release, so
|
|
@@ -806,9 +991,10 @@ async function installHook(o, root, force) {
|
|
|
806
991
|
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
807
992
|
const hooksDir = path.join(root, ".githooks");
|
|
808
993
|
const hookPath = path.join(hooksDir, "pre-push");
|
|
809
|
-
const
|
|
810
|
-
const nodePath =
|
|
811
|
-
|
|
994
|
+
const { provider, declaring } = releasePackages(o, root, git);
|
|
995
|
+
const { nodePath, packages, unsafe } = hookGatePlan({ root, gateFile: o.gateFile, declaring });
|
|
996
|
+
if (unsafe.length) o.err(`rightkit-gate: WARNING: the pre-push hook does not look for the gate in ${unsafe.join(", ")}: not a plain directory name (letters, digits and _ - . @ + only, with / between); it still finds a root or workspace install by glob.`);
|
|
997
|
+
const wanted = hookText(nodePath, packages);
|
|
812
998
|
const hasHook = o.exists(hookPath);
|
|
813
999
|
const current = hasHook ? o.readFile(hookPath).replace(/\r\n/g, "\n") : null;
|
|
814
1000
|
if (hasHook && current !== wanted && !force) {
|
|
@@ -869,30 +1055,276 @@ async function installHook(o, root, force) {
|
|
|
869
1055
|
if (failed(set)) { o.err(`rightkit-gate: git config core.hooksPath failed: ${why(set)}`); return 2; }
|
|
870
1056
|
done.push(`set core.hooksPath to .githooks${existingPath ? ` (was ${existingPath})` : ""}`);
|
|
871
1057
|
}
|
|
1058
|
+
// The agent side of the one door: a Claude Code PreToolUse hook in the repository's .claude/settings.json runs door-policy.mjs on every
|
|
1059
|
+
// shell command, on every host (the broker-less cloud included), and denies any build that does not go through rightkit-gate.
|
|
1060
|
+
const agentHookFile = "node_modules/@rightkit/release/bin/rightkit-agent-hook.mjs";
|
|
1061
|
+
const agentHook = o.exists(path.join(root, agentHookFile)) ? agentHookFile : posix(path.relative(root, fileURLToPath(new URL("./rightkit-agent-hook.mjs", import.meta.url))));
|
|
1062
|
+
const settingsPath = path.join(root, ".claude", "settings.json");
|
|
1063
|
+
let settings = {};
|
|
1064
|
+
if (o.exists(settingsPath)) { try { settings = JSON.parse(o.readFile(settingsPath)); } catch { o.err("rightkit-gate: .claude/settings.json is not JSON; the agent hook was not added"); settings = null; } }
|
|
1065
|
+
if (settings) {
|
|
1066
|
+
const command = `node ${agentHook}`;
|
|
1067
|
+
const pre = Array.isArray(settings.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
1068
|
+
const has = pre.some((entry) => (entry.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes("rightkit-agent-hook")));
|
|
1069
|
+
if (has) done.push(".claude/settings.json agent hook already present");
|
|
1070
|
+
else {
|
|
1071
|
+
settings.hooks = { ...(settings.hooks ?? {}), PreToolUse: [...pre, { matcher: "Bash", hooks: [{ type: "command", command }] }] };
|
|
1072
|
+
try { o.mkdir(path.dirname(settingsPath), { recursive: true }); } catch { /* exists */ }
|
|
1073
|
+
o.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
1074
|
+
done.push("added the one-door agent hook to .claude/settings.json (commit it)");
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
872
1077
|
for (const line of done) o.out(`rightkit-gate: ${line}`);
|
|
873
1078
|
const chained = own.length ? own.map((name) => (name === "pre-push" ? "pre-push (run first, then the gate)" : name)) : [];
|
|
874
1079
|
o.out(`rightkit-gate: chained hooks from .git/hooks: ${chained.length ? chained.join(", ") : "none"}${own.includes("pre-push") ? "" : "; the pre-push hook still runs .git/hooks/pre-push first if one appears later"}`);
|
|
875
1080
|
if (!ownership) o.out(`rightkit-gate: no ownership pre-commit step: ${PINNED_CLI} is not installed here; install @rightkit/release, then run install-hook again`);
|
|
876
1081
|
const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
|
|
877
|
-
o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, else of any workspace package, else through \`pnpm exec
|
|
878
|
-
const provider = releaseProvider(o, root, git);
|
|
1082
|
+
o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, ${packages.length ? `else of ${packages.join(", ")} (the packages that declare @rightkit/release), else of any workspace package, else through \`pnpm --dir ${packages[0]} exec\`` : "else of any workspace package, else through \`pnpm exec\`"}.`);
|
|
879
1083
|
if (!provider) o.err("rightkit-gate: WARNING: no package.json in this repository depends on @rightkit/release, so the pre-push hook cannot find the rightkit-gate binary; add @rightkit/release as a devDependency of the root or of a workspace package and install.");
|
|
880
1084
|
else o.out(`rightkit-gate: @rightkit/release is declared in ${provider}`);
|
|
881
1085
|
return 0;
|
|
882
1086
|
}
|
|
883
1087
|
|
|
1088
|
+
// ---- the stages: rightkit-gate --to <stage> --------------------------------------------------------------------------------
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Runs every stage up to `--to`, in order, for HEAD: check (class A), compile (the whole gate over every file), then, through the existing
|
|
1092
|
+
* owners, package (source QA, then `right-release build`, which seals the exact artifact set), qualify (installed QA on that sealed set)
|
|
1093
|
+
* and release (`right-release upload` of that same sealed set). Stops at the first stage that fails. check and compile are reused from
|
|
1094
|
+
* the receipt only when every bound input is unchanged; the right-release stages run under a live door run, so they refuse any other
|
|
1095
|
+
* caller (gate-stages.mjs requireDoorRun).
|
|
1096
|
+
*/
|
|
1097
|
+
async function runStages(o, args, root, git, io) {
|
|
1098
|
+
const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
|
|
1099
|
+
const tag = "rightkit-gate:";
|
|
1100
|
+
const to = option("--to");
|
|
1101
|
+
if (to === "dev" || to === "test") return await runWorktreeCompile(o, args, root, git, io, to);
|
|
1102
|
+
const stages = stagesTo(to);
|
|
1103
|
+
if (!stages) { o.err(`${tag} --to must be one of ${STAGES.join(", ")}`); return 2; }
|
|
1104
|
+
const platform = option("--platform");
|
|
1105
|
+
const tier = option("--tier");
|
|
1106
|
+
if (stages.includes("package") && !["mac", "win"].includes(platform)) { o.err(`${tag} --to ${to} needs --platform mac|win`); return 2; }
|
|
1107
|
+
if (stages.includes("release") && !["patch", "update"].includes(tier)) { o.err(`${tag} --to release needs --tier patch|update (chosen explicitly, never defaulted)`); return 2; }
|
|
1108
|
+
const commit = text(git("rev-parse", "HEAD")).trim();
|
|
1109
|
+
const tree = text(git("rev-parse", "HEAD^{tree}")).trim();
|
|
1110
|
+
const common = text(git("rev-parse", "--git-common-dir")).trim();
|
|
1111
|
+
if (!/^[0-9a-f]{40}$/.test(commit) || !tree || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
|
|
1112
|
+
const commonDir = path.resolve(root, common);
|
|
1113
|
+
const dirty = git("status", "--porcelain", "--untracked-files=no");
|
|
1114
|
+
if (failed(dirty) || text(dirty).trim()) { o.err(`${tag} BLOCKED: the checkout differs from HEAD; every stage works on the commit, so commit or stash first`); return 1; }
|
|
1115
|
+
|
|
1116
|
+
const which = (cmd) => { const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]); return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); };
|
|
1117
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
|
|
1118
|
+
if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1119
|
+
const version = (cmd, a) => { const r = o.spawn(cmd, a, { cwd: root }); return failed(r) ? null : text(r).trim().split(/\r?\n/)[0]; };
|
|
1120
|
+
const listed = git("ls-tree", "-r", "--name-only", "-z", "HEAD");
|
|
1121
|
+
const lockFiles = {};
|
|
1122
|
+
for (const f of failed(listed) ? [] : text(listed).split("\0").filter((x) => /(^|\/)(Cargo\.lock|pnpm-lock\.yaml)$/.test(x))) {
|
|
1123
|
+
const shown = git("show", `HEAD:${f}`); if (!failed(shown)) lockFiles[f] = text(shown);
|
|
1124
|
+
}
|
|
1125
|
+
const binding = bindingOf({
|
|
1126
|
+
tree, lockFiles, gateFile: fileURLToPath(import.meta.url), executor: executor.kind,
|
|
1127
|
+
toolchain: { classA: { "cargo-deny": version("cargo-deny", ["--version"]), "cargo-audit": version("cargo-audit", ["--version"]), actionlint: version("actionlint", ["-version"]), shellcheck: version("shellcheck", ["--version"]) }, node: process.version, rustc: version(executor.kind === "broker" && executor.cargo[0] === "rustright" ? "rustright" : "rustc", executor.kind === "broker" && executor.cargo[0] === "rustright" ? ["rustc", "-V"] : ["-V"]), pnpm: version("pnpm", ["--version"]) },
|
|
1128
|
+
});
|
|
1129
|
+
// Release writes to R2. The write credential lives only in the trusted publisher: a GitHub Actions job in the protected
|
|
1130
|
+
// publication environment, which sets RIGHTKIT_PUBLISHER=github-environment. Hosts and agents never hold it, so the door refuses the
|
|
1131
|
+
// stage anywhere else rather than fail half way through an upload.
|
|
1132
|
+
if (stages.includes("release") && !(o.env.GITHUB_ACTIONS === "true" && o.env.RIGHTKIT_PUBLISHER === "github-environment")) {
|
|
1133
|
+
o.err(`${tag} BLOCKED: --to release runs only in the trusted publisher (the release workflow's protected publication job). Run --to qualify here, then dispatch the release workflow for ${commit.slice(0, 12)}`);
|
|
1134
|
+
return 1;
|
|
1135
|
+
}
|
|
1136
|
+
// --receipt-in <file>: the receipt an earlier job of the same pipeline wrote (another runner: CI's package job, then its qualify and
|
|
1137
|
+
// publish jobs). It is taken only for this commit and the same source binding (tree, lockfiles, gate); it then stands in for the
|
|
1138
|
+
// stages it records, and the sealed bytes on disk must still hash to its manifest digest before anything uses them.
|
|
1139
|
+
const receiptIn = option("--receipt-in");
|
|
1140
|
+
if (receiptIn !== undefined) {
|
|
1141
|
+
let imported = null;
|
|
1142
|
+
try { imported = JSON.parse(o.readFile(path.resolve(o.cwd, receiptIn))); } catch { /* refused below */ }
|
|
1143
|
+
if (!imported || imported.schema !== RECEIPT_SCHEMA || imported.commit !== commit || !sameSource(imported.binding, binding)) {
|
|
1144
|
+
o.err(`${tag} BLOCKED: --receipt-in ${receiptIn} is not a ${RECEIPT_SCHEMA} receipt for ${commit.slice(0, 12)} with this tree, these lockfiles and this gate`);
|
|
1145
|
+
return 1;
|
|
1146
|
+
}
|
|
1147
|
+
(o.writeReceipt ?? writeReceipt)({ commonDir, receipt: imported });
|
|
1148
|
+
}
|
|
1149
|
+
const previous = (o.readReceipt ?? readReceipt)({ commonDir, commit });
|
|
1150
|
+
// Same host and inputs: every recorded stage counts. Same source on another host: only the stages bound to sealed bytes (package and
|
|
1151
|
+
// later) carry over; check and compile run again here.
|
|
1152
|
+
const receipt = previous && sameBinding(previous.binding, binding) ? previous
|
|
1153
|
+
: previous && sameSource(previous.binding, binding) ? { ...previous, binding, stages: Object.fromEntries(Object.entries(previous.stages ?? {}).filter(([k]) => k !== "check" && k !== "compile")) }
|
|
1154
|
+
: { schema: RECEIPT_SCHEMA, commit, binding, stages: {} };
|
|
1155
|
+
const receiptOut = option("--receipt-out");
|
|
1156
|
+
const save = () => {
|
|
1157
|
+
(o.writeReceipt ?? writeReceipt)({ commonDir, receipt });
|
|
1158
|
+
if (receiptOut !== undefined) o.writeFile(path.resolve(o.cwd, receiptOut), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
|
|
1162
|
+
const rightRelease = (stage, cliArgs) => {
|
|
1163
|
+
const run = startRun({ commonDir, commit, tree, stage });
|
|
1164
|
+
try {
|
|
1165
|
+
o.out(`${tag} [${stage}] right-release ${cliArgs.join(" ")}`);
|
|
1166
|
+
const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, ...cliArgs], { cwd: root, env: { ...o.env, ...run.env } });
|
|
1167
|
+
return r.status === 0;
|
|
1168
|
+
} finally { endRun(run); }
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
o.out(`${tag} --to ${to} for ${commit.slice(0, 12)}: ${stages.join(" -> ")}; executor ${executor.kind}`);
|
|
1172
|
+
// A later stage on the sealed bytes: check, compile and package already passed for them (in this receipt) and the bytes on disk are
|
|
1173
|
+
// the ones they sealed, so they are not run again.
|
|
1174
|
+
const sealedNow = receipt.stages.package?.ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
|
|
1175
|
+
const sealedIntact = Boolean(sealedNow && sealedNow.id === receipt.stages.package.sealed && sealedNow.digest === receipt.stages.package.manifestSha256);
|
|
1176
|
+
for (const stage of stages) {
|
|
1177
|
+
if ((stage === "check" || stage === "compile") && receipt.stages[stage]?.ok) { o.out(`${tag} [${stage}] already passed for this exact input (commit, tree, lockfiles, gate, toolchain)`); continue; }
|
|
1178
|
+
if ((stage === "check" || stage === "compile" || stage === "package") && sealedIntact) {
|
|
1179
|
+
o.out(`${tag} [${stage}] already passed for the sealed set ${sealedNow.id} (manifest ${sealedNow.digest.slice(0, 12)})`); continue;
|
|
1180
|
+
}
|
|
1181
|
+
// The publisher (another runner, often another OS) cannot repeat installed QA; it takes the qualify result recorded for exactly
|
|
1182
|
+
// these sealed bytes. --to qualify itself always runs it.
|
|
1183
|
+
if (stage === "qualify" && to !== "qualify" && sealedIntact && receipt.stages.qualify?.ok && receipt.stages.qualify.manifestSha256 === sealedNow.digest) {
|
|
1184
|
+
o.out(`${tag} [qualify] already passed for the sealed set ${sealedNow.id} (manifest ${sealedNow.digest.slice(0, 12)})`); continue;
|
|
1185
|
+
}
|
|
1186
|
+
// The publisher only uploads. It never checks out a build environment, signs or runs installed QA, so every earlier stage must
|
|
1187
|
+
// already stand in its receipt for exactly the sealed bytes on disk; anything else is refused, never rebuilt or requalified here.
|
|
1188
|
+
if (to === "release" && stage !== "release") {
|
|
1189
|
+
o.err(`${tag} BLOCKED: [${stage}] --to release only uploads: the receipt must already record ${stage} for the sealed bytes on disk (${sealedNow ? `manifest ${sealedNow.digest.slice(0, 12)}` : "none found"}); run --to qualify on the host and stage again`);
|
|
1190
|
+
return 1;
|
|
1191
|
+
}
|
|
1192
|
+
let ok = false; const at = new Date().toISOString(); let extra = {};
|
|
1193
|
+
if (stage === "check") ok = (await (o.runCheck ?? (() => main(["check", "--commit", "HEAD"], { ...io, cwd: root })))()) === 0;
|
|
1194
|
+
else if (stage === "compile") {
|
|
1195
|
+
// A Tauri crate with externalBin needs its sidecars in src-tauri/binaries before it compiles (its build script refuses
|
|
1196
|
+
// otherwise), so every release config that declares `sidecars` is staged first, through right-release under this door run.
|
|
1197
|
+
const listedConfigs = git("ls-tree", "-r", "--name-only", "-z", "HEAD");
|
|
1198
|
+
const sidecarConfigs = (failed(listedConfigs) ? [] : text(listedConfigs).split("\0").filter((f) => /(^|\/)right-release\.config\.(json|mjs)$/.test(f)))
|
|
1199
|
+
.filter((f) => { try { return /\bsidecars\b/.test(o.readFile(path.join(root, f))); } catch { return false; } });
|
|
1200
|
+
ok = true;
|
|
1201
|
+
for (const config of sidecarConfigs) if (!rightRelease(stage, ["stage-sidecar", "--config", config])) { ok = false; break; }
|
|
1202
|
+
if (ok) ok = (await (o.runCompile ?? (() => main(["--all"], { ...io, cwd: root, readStdin: () => "" })))()) === 0;
|
|
1203
|
+
}
|
|
1204
|
+
else if (stage === "package") {
|
|
1205
|
+
ok = rightRelease(stage, ["qa", "--stage", "source", "--platform", platform]) && rightRelease(stage, ["build", "--platform", platform]);
|
|
1206
|
+
const sealed = ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
|
|
1207
|
+
if (ok && !sealed) { o.err(`${tag} [package] the build reported success but sealed nothing for ${commit.slice(0, 8)} on ${platform}`); ok = false; }
|
|
1208
|
+
if (sealed) extra = { platform, sealed: sealed.id, manifestSha256: sealed.digest };
|
|
1209
|
+
} else {
|
|
1210
|
+
const sealed = receipt.stages.package?.ok ? receipt.stages.package : null;
|
|
1211
|
+
const current = sealed ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
|
|
1212
|
+
if (!sealed || !current || current.id !== sealed.sealed || current.digest !== sealed.manifestSha256) {
|
|
1213
|
+
o.err(`${tag} BLOCKED: [${stage}] needs the sealed set the package stage produced for this commit, unchanged (sealed manifest digest)`);
|
|
1214
|
+
ok = false;
|
|
1215
|
+
} else if (stage === "qualify") {
|
|
1216
|
+
ok = rightRelease(stage, ["qa", "--stage", "installed", "--platform", platform, "--release", sealed.sealed]);
|
|
1217
|
+
extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256 };
|
|
1218
|
+
} else {
|
|
1219
|
+
if (!receipt.stages.qualify?.ok || receipt.stages.qualify.manifestSha256 !== sealed.manifestSha256) { o.err(`${tag} BLOCKED: [release] the sealed set was not qualified (installed QA) in this door run's receipt`); ok = false; }
|
|
1220
|
+
else { ok = rightRelease(stage, ["upload", "--release", sealed.sealed, "--platform", platform, "--tier", tier]); extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256, tier }; }
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
receipt.stages[stage] = { ok, at, ...extra };
|
|
1224
|
+
save();
|
|
1225
|
+
if (!ok) { o.err(`${tag} --to ${to} stopped at ${stage}: nothing after it ran`); return 1; }
|
|
1226
|
+
}
|
|
1227
|
+
o.out(`${tag} --to ${to} passed: ${stages.join(", ")}`);
|
|
1228
|
+
return 0;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* rightkit-gate stage --platform mac|win [--tier patch|update]: hands a qualified sealed set to the trusted publisher. The host signed,
|
|
1233
|
+
* sealed and qualified it (--to qualify); only the publisher holds the R2 write credential. Requires this host's receipt for HEAD with
|
|
1234
|
+
* package and qualify passed on the sealed bytes as they are now. Packs the sealed directory and the receipt into the draft GitHub
|
|
1235
|
+
* release `rightkit-staging-<commit>` (replacing that platform's asset), then, with --tier, dispatches RightKit's publish-app workflow
|
|
1236
|
+
* (the one trusted publisher for every app) for HEAD.
|
|
1237
|
+
*/
|
|
1238
|
+
const PUBLISHER_REPO = "bogusyogi/rightkit";
|
|
1239
|
+
function stageForPublisher(o, args, root, git) {
|
|
1240
|
+
const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
|
|
1241
|
+
const tag = "rightkit-gate:";
|
|
1242
|
+
const platform = option("--platform");
|
|
1243
|
+
const tier = option("--tier");
|
|
1244
|
+
if (!["mac", "win"].includes(platform)) { o.err(`${tag} stage needs --platform mac|win`); return 2; }
|
|
1245
|
+
if (tier !== undefined && !["patch", "update"].includes(tier)) { o.err(`${tag} stage --tier must be patch or update`); return 2; }
|
|
1246
|
+
const commit = text(git("rev-parse", "HEAD")).trim();
|
|
1247
|
+
const common = text(git("rev-parse", "--git-common-dir")).trim();
|
|
1248
|
+
if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
|
|
1249
|
+
const commonDir = path.resolve(root, common);
|
|
1250
|
+
const receipt = (o.readReceipt ?? readReceipt)({ commonDir, commit });
|
|
1251
|
+
const pack = receipt?.stages?.package; const qual = receipt?.stages?.qualify;
|
|
1252
|
+
const sealed = pack?.ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
|
|
1253
|
+
if (!pack?.ok || !qual?.ok || pack.platform !== platform || qual.manifestSha256 !== pack.manifestSha256 || !sealed || sealed.id !== pack.sealed || sealed.digest !== pack.manifestSha256) {
|
|
1254
|
+
o.err(`${tag} BLOCKED: stage needs this host's receipt for ${commit.slice(0, 12)} with package and qualify passed on ${platform} and the sealed set unchanged; run rightkit-gate --to qualify --platform ${platform} first`);
|
|
1255
|
+
return 1;
|
|
1256
|
+
}
|
|
1257
|
+
const releaseTag = `rightkit-staging-${commit}`;
|
|
1258
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "rightkit-stage-"));
|
|
1259
|
+
try {
|
|
1260
|
+
const receiptFile = path.join(work, `receipt-${platform}.json`);
|
|
1261
|
+
fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
1262
|
+
const archive = path.join(work, `sealed-${platform}.tar.gz`);
|
|
1263
|
+
const tar = o.spawn("tar", ["-czf", archive, "-C", root, path.join(".right-release", "sealed", sealed.id, platform)]);
|
|
1264
|
+
if (failed(tar)) { o.err(`${tag} could not pack ${sealed.id}/${platform}`); return 1; }
|
|
1265
|
+
const gh = (...a) => o.spawn("gh", a, { cwd: root });
|
|
1266
|
+
if (failed(gh("release", "view", releaseTag))) {
|
|
1267
|
+
const made = gh("release", "create", releaseTag, "--draft", "--target", commit, "--title", `staging ${commit.slice(0, 12)}`, "--notes", "Sealed and qualified sets for the trusted publisher; not a release.");
|
|
1268
|
+
if (failed(made)) { o.err(`${tag} could not create the draft ${releaseTag}: ${text(made).trim()}`); return 1; }
|
|
1269
|
+
}
|
|
1270
|
+
const up = gh("release", "upload", releaseTag, archive, receiptFile, "--clobber");
|
|
1271
|
+
if (failed(up)) { o.err(`${tag} could not upload to ${releaseTag}: ${text(up).trim()}`); return 1; }
|
|
1272
|
+
o.out(`${tag} staged ${sealed.id}/${platform} (manifest ${sealed.digest.slice(0, 12)}) in draft ${releaseTag}`);
|
|
1273
|
+
if (tier) {
|
|
1274
|
+
// The trusted publisher is RightKit's publish-app workflow: app repositories are private and run no Actions.
|
|
1275
|
+
const slug = text(gh("repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner")).trim();
|
|
1276
|
+
const appDir = path.relative(root, o.cwd).replaceAll("\\", "/") || ".";
|
|
1277
|
+
const run = gh("workflow", "run", "publish-app.yml", "-R", PUBLISHER_REPO, "-f", `app=${slug}`, "-f", `app_dir=${appDir}`, "-f", `source_revision=${commit}`, "-f", `tier=${tier}`, "-f", `platforms=${platform}`);
|
|
1278
|
+
if (!slug || failed(run)) { o.err(`${tag} staged, but could not dispatch ${PUBLISHER_REPO} publish-app.yml: ${text(run).trim()}`); return 1; }
|
|
1279
|
+
o.out(`${tag} dispatched ${PUBLISHER_REPO} publish-app.yml for ${slug}@${commit.slice(0, 12)} (${platform}, ${tier}); the publisher re-runs class A, verifies the receipt and the bytes, then uploads`);
|
|
1280
|
+
}
|
|
1281
|
+
return 0;
|
|
1282
|
+
} finally { fs.rmSync(work, { recursive: true, force: true }); }
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* rightkit-gate --to dev|test [-- <right-release dev|test arguments>]: the door for the local compiles that work on the uncommitted tree.
|
|
1287
|
+
* Class A runs over the working tree first (nothing compiles while it is red), then `right-release dev|test` runs under a live door
|
|
1288
|
+
* run, the only way it runs at all. The executor is the same as every other stage: the broker where there is one (its own admission
|
|
1289
|
+
* checks again), plain cargo on an unmanaged host, never around a managed host's broker.
|
|
1290
|
+
*/
|
|
1291
|
+
async function runWorktreeCompile(o, args, root, git, io, to) {
|
|
1292
|
+
const tag = "rightkit-gate:";
|
|
1293
|
+
const passthrough = args.includes("--") ? args.slice(args.indexOf("--") + 1) : [];
|
|
1294
|
+
const commit = text(git("rev-parse", "HEAD")).trim();
|
|
1295
|
+
const tree = text(git("rev-parse", "HEAD^{tree}")).trim();
|
|
1296
|
+
const common = text(git("rev-parse", "--git-common-dir")).trim();
|
|
1297
|
+
if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
|
|
1298
|
+
const which = (cmd) => { const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]); return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); };
|
|
1299
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
|
|
1300
|
+
if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1301
|
+
o.out(`${tag} --to ${to} on the working tree of ${commit.slice(0, 12)}: check (class A) -> right-release ${to}; executor ${executor.kind}`);
|
|
1302
|
+
const checked = await (o.runCheck ?? (() => main(["check", "--worktree"], { ...io, cwd: root })))();
|
|
1303
|
+
if (checked !== 0) { o.err(`${tag} --to ${to} stopped at check: nothing compiled`); return 1; }
|
|
1304
|
+
const commonDir = path.resolve(root, common);
|
|
1305
|
+
const run = startRun({ commonDir, commit, tree, stage: to });
|
|
1306
|
+
try {
|
|
1307
|
+
const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
|
|
1308
|
+
const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, to, ...passthrough], { cwd: o.cwd, env: { ...o.env, ...run.env } });
|
|
1309
|
+
return r.status === 0 ? 0 : 1;
|
|
1310
|
+
} finally { endRun(run); }
|
|
1311
|
+
}
|
|
1312
|
+
|
|
884
1313
|
// ---- main ---------------------------------------------------------------------------------------------------
|
|
885
1314
|
|
|
886
1315
|
/**
|
|
887
1316
|
* Run the CLI. Returns the exit code (0 pass, 1 blocked or failed, 2 cannot run, 128+n cancelled). `io` overrides the
|
|
888
1317
|
* process, fs and output bindings so tests execute nothing real: spawn, exists, readFile, writeFile, mkdir, chmod,
|
|
889
|
-
* readStdin, out, err, cwd, env, platform, abort (an AbortController)
|
|
1318
|
+
* readStdin, out, err, cwd, env, platform, abort (an AbortController), runScans and findRustfmt (the scan runner and the
|
|
1319
|
+
* lookup of a usable rustfmt, which the deferred Rust gate needs).
|
|
890
1320
|
*/
|
|
891
1321
|
export async function main(argv, io = {}) {
|
|
892
1322
|
const o = {
|
|
893
1323
|
spawn: defaultSpawn, exists: fs.existsSync, readFile: (f) => fs.readFileSync(f, "utf8"), writeFile: fs.writeFileSync, mkdir: fs.mkdirSync, chmod: fs.chmodSync, readdir: fs.readdirSync,
|
|
894
1324
|
readStdin: () => { try { return process.stdin.isTTY ? "" : fs.readFileSync(0, "utf8"); } catch { return ""; } },
|
|
895
|
-
out: (m) => console.log(m), err: (m) => console.error(m), cwd: process.cwd(), env: process.env, platform: process.platform,
|
|
1325
|
+
out: (m) => console.log(m), err: (m) => console.error(m), cwd: process.cwd(), env: process.env, platform: process.platform,
|
|
1326
|
+
// Where this gate runs from (install-hook writes a path to it only when it is a plain path inside the repository: hookGatePlan).
|
|
1327
|
+
gateFile: fileURLToPath(import.meta.url), ...io,
|
|
896
1328
|
};
|
|
897
1329
|
const args = [...argv];
|
|
898
1330
|
const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
|
|
@@ -902,9 +1334,82 @@ export async function main(argv, io = {}) {
|
|
|
902
1334
|
}
|
|
903
1335
|
if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
|
|
904
1336
|
const sub = args[0] && !args[0].startsWith("-") ? args[0] : null;
|
|
905
|
-
if (sub && sub !== "install-hook" && sub !== "check") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
|
|
906
|
-
//
|
|
907
|
-
|
|
1337
|
+
if (sub && sub !== "install-hook" && sub !== "check" && sub !== "stage") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
|
|
1338
|
+
// `check` is the deterministic stage (Adrian, 2026-10-25: deterministic always first, one door): the app-check rules, then every
|
|
1339
|
+
// class A scan (lockfiles, cargo-deny, cargo-audit, secrets, licences, manifests, workflow lint, syntax...) over the whole tree of the
|
|
1340
|
+
// commit, never a diff. Nothing compiles. The RustRight broker runs exactly this before it admits any compile. `--list`,
|
|
1341
|
+
// `--print-baseline` and `--only` stay app-check-only views.
|
|
1342
|
+
if (sub === "check") {
|
|
1343
|
+
const appCheck = await (o.appCheck ?? (await import("../app-check.mjs")).main)(args.slice(1), { out: o.out, err: o.err, label: "check", cwd: o.cwd });
|
|
1344
|
+
if (args.includes("--list") || args.includes("--print-baseline") || args.includes("--only") || o.classA === false) {
|
|
1345
|
+
// A view of the app rules, never a class A verdict: say so, so no caller can take its exit 0 for a full check.
|
|
1346
|
+
// A green partial view exits PARTIAL_EXIT (3), never 0, so a caller that admits only exit 0 rejects it by status alone.
|
|
1347
|
+
if (args.includes("--list") || args.includes("--print-baseline")) return appCheck;
|
|
1348
|
+
o.err("rightkit-gate: PARTIAL: app rules only (--only); no class A scan ran, so this is not a class A result");
|
|
1349
|
+
return appCheck === 0 ? PARTIAL_EXIT : appCheck;
|
|
1350
|
+
}
|
|
1351
|
+
const repoArg = option("--repo");
|
|
1352
|
+
const checkCwd = repoArg ? path.resolve(o.cwd, repoArg) : o.cwd;
|
|
1353
|
+
const classA = await main(["--class-a", ...(args.includes("--worktree") ? ["--worktree"] : ["--commit", option("--commit") ?? "HEAD"])], { ...io, cwd: checkCwd });
|
|
1354
|
+
// RightKit checking itself: its own rules (line endings, consumer recipes, npm surface, version copies and the version feed, dispatcher
|
|
1355
|
+
// pins, consumer locks, generated docs, temp paths: scripts/preflight.mjs) and its exports' import safety are class A for the RightKit repository. They run only when this gate is that
|
|
1356
|
+
// repository's own in-tree copy, so a gate checking any other repository (the broker's pinned one included) never runs
|
|
1357
|
+
// that repository's code.
|
|
1358
|
+
let selfRules = 0;
|
|
1359
|
+
const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: checkCwd });
|
|
1360
|
+
const root = failed(top) ? null : path.resolve(text(top).trim());
|
|
1361
|
+
const self = o.selfPath ?? fileURLToPath(import.meta.url);
|
|
1362
|
+
// RightKit's own scripts are the checked repository's code: they run only after the app rules and every class A scan passed,
|
|
1363
|
+
// never after a failure, so a red tree executes nothing of its own.
|
|
1364
|
+
// Compared as real, long paths (and case-blind on Windows): git prints the long form of an 8.3 short path such as RUNNER~1.
|
|
1365
|
+
const canon = (p) => { let c; try { c = fs.realpathSync.native(p); } catch { c = path.resolve(p); } return process.platform === "win32" ? c.toLowerCase() : c; };
|
|
1366
|
+
if (root && canon(self).startsWith(canon(root) + path.sep) && o.exists(path.join(root, "scripts", "preflight.mjs")) && appCheck === 0 && classA === 0) {
|
|
1367
|
+
const launch = o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" }));
|
|
1368
|
+
const selector = args.includes("--worktree") ? ["--worktree"] : ["--commit", option("--commit") ?? "HEAD"];
|
|
1369
|
+
const r = launch(process.execPath, [path.join(root, "scripts", "preflight.mjs"), "--repo", root, ...selector], { cwd: root, env: o.env });
|
|
1370
|
+
selfRules = r.status === 0 ? 0 : 1;
|
|
1371
|
+
// And its exports import cleanly (scripts/ci/exports-import-effects.mjs: every exported module imported in a fresh, permission-
|
|
1372
|
+
// limited process; an import that prints, exits or reads its argv fails), which publish.yml's scans job ran alone before. That
|
|
1373
|
+
// check probes the checkout's HEAD, so it runs only when HEAD is the requested commit (or the working tree was asked for);
|
|
1374
|
+
// any other commit is refused rather than proven by other bytes.
|
|
1375
|
+
const effects = path.join(root, "scripts", "ci", "exports-import-effects.mjs");
|
|
1376
|
+
if (selfRules === 0 && o.exists(effects)) {
|
|
1377
|
+
const requested = args.includes("--worktree") ? null : option("--commit") ?? "HEAD";
|
|
1378
|
+
const at = (rev) => { const v = o.spawn("git", ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`], { cwd: root }); return failed(v) ? null : text(v).trim(); };
|
|
1379
|
+
// The probe exports HEAD, so a working tree whose tracked or untracked (unignored) files differ from HEAD is refused too.
|
|
1380
|
+
const dirty = () => { const st = o.spawn("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: root }); return failed(st) || text(st).trim() !== ""; };
|
|
1381
|
+
if (requested !== null && at(requested) !== at("HEAD")) {
|
|
1382
|
+
o.err(`rightkit-gate: BLOCKED: the exports import check probes the checkout's HEAD (${String(at("HEAD")).slice(0, 12)}), not the requested ${requested}; check out that commit to check it`);
|
|
1383
|
+
selfRules = 1;
|
|
1384
|
+
} else if (requested === null && dirty()) {
|
|
1385
|
+
o.err("rightkit-gate: BLOCKED: the exports import check probes the checkout's HEAD, and the working tree differs from it; commit or stash the changes, or check --commit HEAD");
|
|
1386
|
+
selfRules = 1;
|
|
1387
|
+
} else {
|
|
1388
|
+
// Like CI's scans job: exports that resolve to build output are INCOMPLETE here and handed, through --report, to the
|
|
1389
|
+
// sealed-byte check that publication runs; without --report the probe exits 3 and a green tree could never pass.
|
|
1390
|
+
const common = o.spawn("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root });
|
|
1391
|
+
const report = path.join(failed(common) ? path.join(root, ".git") : text(common).trim(), "rightkit-gate", "import-effects", "report.json");
|
|
1392
|
+
const e = launch(process.execPath, [effects, "--report", report], { cwd: root, env: o.env });
|
|
1393
|
+
if (e.status !== 0) selfRules = 1;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
// A RustRight broker's check names the tree it captured for the compile: the declared inputs there (ignored files the
|
|
1398
|
+
// commit does not hold) are judged too, as the bytes the compile will read.
|
|
1399
|
+
let inputs = 0;
|
|
1400
|
+
const captured = o.env.RIGHTKIT_COMPILE_INPUTS;
|
|
1401
|
+
if (captured) {
|
|
1402
|
+
const { judgeCompileInputs } = await import("../compile-inputs.mjs");
|
|
1403
|
+
const judged = judgeCompileInputs(path.resolve(captured));
|
|
1404
|
+
for (const i of judged.inputs) o.out(`rightkit-gate: compile input ${i.path}: ${i.files} file(s), sha256 ${i.sha256}`);
|
|
1405
|
+
for (const f of judged.findings) o.err(`rightkit-gate: compile input FAIL ${f}`);
|
|
1406
|
+
if (!judged.inputs.length && !judged.findings.length) o.out("rightkit-gate: no declared compile inputs in the captured tree");
|
|
1407
|
+
inputs = judged.findings.length ? 1 : 0;
|
|
1408
|
+
}
|
|
1409
|
+
return Math.max(appCheck, classA, selfRules, inputs);
|
|
1410
|
+
}
|
|
1411
|
+
const classAOnly = args.includes("--class-a");
|
|
1412
|
+
const classACommit = classAOnly && !args.includes("--worktree") ? (option("--commit") ?? "HEAD") : null;
|
|
908
1413
|
if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
|
|
909
1414
|
|
|
910
1415
|
const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: o.cwd });
|
|
@@ -912,16 +1417,26 @@ export async function main(argv, io = {}) {
|
|
|
912
1417
|
const root = path.resolve(text(top).trim());
|
|
913
1418
|
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
914
1419
|
if (sub === "install-hook") return await installHook(o, root, args.includes("--force"));
|
|
1420
|
+
if (option("--to") !== undefined) return await runStages(o, args, root, git, io);
|
|
1421
|
+
if (sub === "stage") return stageForPublisher(o, args, root, git);
|
|
915
1422
|
|
|
916
1423
|
const planOnly = args.includes("--plan");
|
|
917
1424
|
let base = option("--base") ?? "origin/main";
|
|
918
1425
|
const tag = "rightkit-gate:";
|
|
919
1426
|
|
|
920
1427
|
// git pre-push: "<local ref> <local sha> <remote ref> <remote sha>" per pushed ref. A plan reads no stdin.
|
|
921
|
-
|
|
1428
|
+
// git's push lines are split on single ASCII spaces only (parsePushLines): a ref name may hold Unicode whitespace. A line that does not read as four fields
|
|
1429
|
+
// (git hands the local field over verbatim, so a rev expression such as `HEAD@{1 minute ago}` or `HEAD^{/one thing}` holds spaces)
|
|
1430
|
+
// refuses the push on every host and in every repository, never a line that disappears: its pushed commit could not be compared with
|
|
1431
|
+
// HEAD and its remote ref could not be judged. `pushedRemoteShas` are the remote commits being replaced.
|
|
1432
|
+
if (!planOnly && !classAOnly) {
|
|
922
1433
|
const head = text(git("rev-parse", "HEAD")).trim();
|
|
923
|
-
const
|
|
924
|
-
|
|
1434
|
+
const parsed = parsePushLines(o.readStdin());
|
|
1435
|
+
if (parsed.malformed.length) {
|
|
1436
|
+
o.err(`${tag} BLOCKED: pre-push stdin line ${parsed.malformed[0].number} is not "<local ref> <local sha> <remote ref> <remote sha>" (four fields separated by single spaces), so the pushed refs cannot all be read and the pushed commit cannot be compared with HEAD; push a plain ref (git push <remote> HEAD:<branch>)`);
|
|
1437
|
+
return 1;
|
|
1438
|
+
}
|
|
1439
|
+
for (const { localSha, remoteRef, remoteSha } of parsed.lines) {
|
|
925
1440
|
if (ZERO.test(localSha)) continue; // a delete pushes no content
|
|
926
1441
|
if (localSha !== head) {
|
|
927
1442
|
o.err(`${tag} BLOCKED: pushing ${localSha.slice(0, 10)} to ${remoteRef}, but HEAD is ${head.slice(0, 10)}; broker scans read the checkout, so check the pushed commit out first (git checkout --detach ${localSha.slice(0, 10)}), push HEAD:<branch>, then return`);
|
|
@@ -931,51 +1446,16 @@ export async function main(argv, io = {}) {
|
|
|
931
1446
|
}
|
|
932
1447
|
}
|
|
933
1448
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
};
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
const manifests = tracked("Cargo.toml");
|
|
945
|
-
if (manifests.length) {
|
|
946
|
-
cargoCommand = resolveCargoCommand(o);
|
|
947
|
-
if (!cargoCommand) { o.err(`${tag} BLOCKED: neither \`rustright\` nor the managed cargo shim (cargo-guard.mjs) is first on PATH; plain cargo is never used`); return 1; }
|
|
948
|
-
if (!o.exists(path.join(root, GRANT_FILE))) { o.err(`${tag} BLOCKED: no local compile grant (${GRANT_FILE}); the broker refuses cargo, so the Rust units cannot be discovered or scanned`); return 1; }
|
|
949
|
-
}
|
|
950
|
-
// Untracked (not ignored) manifests only: git does not descend into ignored directories, so this stays cheap. A member of a
|
|
951
|
-
// workspace that is untracked or ignored is dropped by discoverRustWorkspaces itself.
|
|
952
|
-
const untracked = () => {
|
|
953
|
-
const r = git("ls-files", "-z", "--others", "--exclude-standard", "--", ":(glob)**/Cargo.toml");
|
|
954
|
-
return failed(r) ? [] : text(r).split("\0").filter(Boolean).map(posix);
|
|
955
|
-
};
|
|
956
|
-
// ci/gate.config.json `fixtures` (optional): directories whose manifests are consumer or sample crates, gated through the
|
|
957
|
-
// consumer rows of the crates they exercise (CI treats them the same: scripts/ci/gate.mjs consumerRowInvocations), never as units.
|
|
958
|
-
let fixtures = [];
|
|
959
|
-
try { const f = JSON.parse(o.readFile(path.join(root, GATE_CONFIG))).fixtures; if (Array.isArray(f)) fixtures = f.filter((d) => typeof d === "string"); } catch { /* no config: no fixtures */ }
|
|
960
|
-
rust = discoverRustWorkspaces({ root, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, tracked, untracked, fixtures, readFile: readLive });
|
|
961
|
-
npm = discoverNpmUnits({ root, tracked, readFile: readLive, exists: o.exists });
|
|
962
|
-
} catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
|
|
963
|
-
for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
|
|
964
|
-
|
|
965
|
-
// Broker scans (cargo, pnpm) read the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
|
|
966
|
-
// otherwise hide a committed failure. Any change or untracked file under those paths blocks the gate.
|
|
967
|
-
if (!planOnly) {
|
|
968
|
-
const inputs = [...new Set([...rust.units.map((u) => u.dir), ...npm.map((u) => u.dir), ...rust.workspaces.map((w) => w.cargoDir), "ci", "scripts", "vendor", "Cargo.toml", "Cargo.lock", ".cargo", "rust-toolchain.toml", "rust-toolchain",
|
|
969
|
-
"deny.toml", "clippy.toml", "rustfmt.toml", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", "eslint.config.js", "eslint.config.mjs", ".eslintrc.json", "biome.json"])];
|
|
970
|
-
const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ...inputs);
|
|
971
|
-
const dirtyFiles = failed(dirty) ? [`git status failed: ${why(dirty)}`]
|
|
972
|
-
: text(dirty).split(/\r?\n/).filter(Boolean).filter((l) => !l.includes("node_modules") && !l.includes(GRANT_FILE));
|
|
973
|
-
if (dirtyFiles.length) {
|
|
974
|
-
o.err(`${tag} BLOCKED: the checkout differs from HEAD where broker scans read it; commit or stash first:`);
|
|
975
|
-
for (const l of dirtyFiles.slice(0, 20)) o.err(` ${l}`);
|
|
976
|
-
return 1;
|
|
977
|
-
}
|
|
978
|
-
}
|
|
1449
|
+
// ---- one door: the executor, then the exact snapshot every scan plans from and reads -----------------------------------
|
|
1450
|
+
// The executor is chosen here, never by the caller (Adrian, 2026-10-25): the RustRight broker where this machine has it, a refusal
|
|
1451
|
+
// on a broker-managed machine whose broker cannot be reached, direct cargo on any other host (a cloud container, a CI runner).
|
|
1452
|
+
const which = (cmd) => { const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]); return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); };
|
|
1453
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
|
|
1454
|
+
if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1455
|
+
const cargoCommand = executor.cargo;
|
|
1456
|
+
// The snapshot: the commit or tree being checked (check --commit), HEAD for the gate, or the working tree (check --worktree, --plan).
|
|
1457
|
+
const allFilesMode = classAOnly || args.includes("--all");
|
|
1458
|
+
const snapshot = planOnly ? null : classAOnly ? classACommit : "HEAD";
|
|
979
1459
|
|
|
980
1460
|
const ac = o.abort ?? new AbortController();
|
|
981
1461
|
let exportRoot = null;
|
|
@@ -983,16 +1463,66 @@ export async function main(argv, io = {}) {
|
|
|
983
1463
|
const signals = [];
|
|
984
1464
|
try {
|
|
985
1465
|
let repo = root;
|
|
986
|
-
if (
|
|
1466
|
+
if (snapshot) {
|
|
987
1467
|
exportRoot = fs.mkdtempSync(path.join(o.env.RK_GATE_TMPDIR || os.tmpdir(), "rk-gate-"));
|
|
988
1468
|
repo = path.join(exportRoot, "repo");
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1469
|
+
// Every file of the commit or tree: read-tree into a private index, then checkout-index. `git archive` would leave out files
|
|
1470
|
+
// marked export-ignore in .gitattributes, so they would never be scanned; this export has no such exclusion.
|
|
1471
|
+
const indexEnv = { ...o.env, GIT_INDEX_FILE: path.join(exportRoot, "index") };
|
|
1472
|
+
const made = o.spawn("git", ["read-tree", snapshot], { cwd: root, env: indexEnv });
|
|
1473
|
+
// git builds each path as <prefix><index path> with "/" separators and creates only the directories below the prefix, so the
|
|
1474
|
+
// export root must exist and the prefix use "/": a "C:\\...\\repo\\" prefix made checkout-index fail on Windows
|
|
1475
|
+
// (publish run 40282215043: "cannot create directory at ...\\repo\\.github").
|
|
1476
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
1477
|
+
const x = failed(made) ? made : o.spawn("git", ["checkout-index", "--all", "--force", `--prefix=${repo.split(path.sep).join("/")}/`], { cwd: root, env: indexEnv });
|
|
1478
|
+
if (failed(made) || failed(x)) { o.err(`${tag} cannot export ${snapshot}: ${why(made) || why(x)}`); return 2; }
|
|
992
1479
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
const
|
|
1480
|
+
// Every file of the snapshot, once: units, manifests, lockfiles and the changed set are all read from what is scanned, never from
|
|
1481
|
+
// the live checkout (a live edit cannot change what gets planned).
|
|
1482
|
+
const listed = snapshot ? git("ls-tree", "-r", "--name-only", "-z", snapshot) : git("ls-files", "-z", "--cached", ...(classAOnly ? ["--others", "--exclude-standard"] : []));
|
|
1483
|
+
if (failed(listed)) { o.err(`${tag} cannot list the files of ${snapshot ?? "the working tree"}: ${why(listed)}`); return 2; }
|
|
1484
|
+
const files = text(listed).split("\0").filter(Boolean).map(posix);
|
|
1485
|
+
const tracked = (name) => files.filter((f) => f === name || f.endsWith(`/${name}`));
|
|
1486
|
+
const readSnap = (f) => o.readFile(f);
|
|
1487
|
+
|
|
1488
|
+
let rust; let npm; let npmRoots = []; let manifests = [];
|
|
1489
|
+
try {
|
|
1490
|
+
manifests = tracked("Cargo.toml");
|
|
1491
|
+
// ci/gate.config.json `fixtures` (optional): directories whose manifests are consumer or sample crates, gated through the
|
|
1492
|
+
// consumer rows of the crates they exercise (CI treats them the same), never as units.
|
|
1493
|
+
let fixtures = [];
|
|
1494
|
+
try { const f = JSON.parse(o.readFile(path.join(repo, GATE_CONFIG))).fixtures; if (Array.isArray(f)) fixtures = f.filter((d) => typeof d === "string"); } catch { /* no config: no fixtures */ }
|
|
1495
|
+
// A snapshot has nothing untracked; the working tree (--plan, check --worktree) names its untracked manifests as skipped.
|
|
1496
|
+
const untracked = () => {
|
|
1497
|
+
if (snapshot) return [];
|
|
1498
|
+
const r = git("ls-files", "-z", "--others", "--exclude-standard", "--", ":(glob)**/Cargo.toml");
|
|
1499
|
+
return failed(r) ? [] : text(r).split("\0").filter(Boolean).map(posix);
|
|
1500
|
+
};
|
|
1501
|
+
rust = discoverRustWorkspaces({ root: repo, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, tracked, untracked, fixtures, readFile: readSnap });
|
|
1502
|
+
npm = discoverNpmUnits({ root: repo, tracked, readFile: readSnap, exists: o.exists });
|
|
1503
|
+
npmRoots = discoverPnpmRoots({ root: repo, tracked, exists: o.exists });
|
|
1504
|
+
} catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
|
|
1505
|
+
for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
|
|
1506
|
+
|
|
1507
|
+
// Class B scans (compile, lint, tests) run in the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
|
|
1508
|
+
// otherwise hide a committed failure. Class A reads only the snapshot.
|
|
1509
|
+
if (!planOnly && !classAOnly) {
|
|
1510
|
+
const npmRootFiles = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", ...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
|
|
1511
|
+
const inputs = [...new Set([...rust.units.map((u) => u.dir), ...npm.map((u) => u.dir), ...rust.workspaces.map((w) => w.cargoDir), "ci", "scripts", "vendor", "Cargo.toml", "Cargo.lock", ".cargo", "rust-toolchain.toml", "rust-toolchain",
|
|
1512
|
+
"deny.toml", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
|
|
1513
|
+
const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ...inputs);
|
|
1514
|
+
const dirtyFiles = failed(dirty) ? [`git status failed: ${why(dirty)}`] : text(dirty).split(/\r?\n/).filter(Boolean).filter((l) => !l.includes("node_modules"));
|
|
1515
|
+
if (dirtyFiles.length) {
|
|
1516
|
+
o.err(`${tag} BLOCKED: the checkout differs from HEAD where compile scans read it; commit or stash first:`);
|
|
1517
|
+
for (const l of dirtyFiles.slice(0, 20)) o.err(` ${l}`);
|
|
1518
|
+
return 1;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const diff = allFilesMode ? null : git("diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`);
|
|
1523
|
+
if (diff && failed(diff)) { o.err(`${tag} cannot diff against ${base}: ${why(diff)}; pass --base <ref> or --all`); return 2; }
|
|
1524
|
+
const changed = allFilesMode ? files : [...new Set(text(diff).split(/\r?\n/).map((f) => f.trim()).filter(Boolean))].filter((f) => o.exists(path.join(repo, f)));
|
|
1525
|
+
if (allFilesMode) base = snapshot ?? "the working tree";
|
|
996
1526
|
|
|
997
1527
|
let crateFeatures = {}; let packageFeatures = {};
|
|
998
1528
|
try { const gateConfig = JSON.parse(o.readFile(path.join(repo, "ci/gate.config.json"))); crateFeatures = gateConfig.rust ?? {}; packageFeatures = gateConfig.npm ?? {}; } catch { /* no config: default feature sets */ }
|
|
@@ -1016,15 +1546,18 @@ export async function main(argv, io = {}) {
|
|
|
1016
1546
|
const rustScope = restricted ? scopeNames : null;
|
|
1017
1547
|
|
|
1018
1548
|
const fullPlan = planScans({
|
|
1019
|
-
|
|
1549
|
+
// Class A of a commit or tree reads only that snapshot: every scan, cargo's included, runs in the export, never in the live
|
|
1550
|
+
// checkout (which may hold other bytes). Compiles (class B) use the live checkout, whose tree the dirty check pinned to HEAD.
|
|
1551
|
+
changed, contentRoot: repo, liveRoot: classAOnly && snapshot ? repo : root, includeTests: true, rustScope, cargoCommand,
|
|
1020
1552
|
// The quiet tests (port, pipe, desktop and wall-clock bound) are proven by the CI shards, one crate per runner, unless
|
|
1021
1553
|
// RK_GATE_QUIET_TESTS=local asks for the serial quiet lane here (no CI, or a CI outage).
|
|
1022
1554
|
quietTests: o.env.RK_GATE_QUIET_TESTS === "local" ? "local" : "ci", packageFeatures,
|
|
1023
|
-
rustUnits: rust.units, npmUnits: npm, cargoDir: ".", rustLocks, crateFeatures,
|
|
1024
|
-
|
|
1555
|
+
rustUnits: rust.units, npmUnits: npm, npmRoots, cargoDir: ".", rustLocks, crateFeatures,
|
|
1556
|
+
// The broker (or, on a direct host, the gate itself) admits compiles; no separate grant file decides it here.
|
|
1557
|
+
localCompile: true,
|
|
1025
1558
|
exists: o.exists, readFile: o.readFile, platform: o.platform,
|
|
1026
1559
|
});
|
|
1027
|
-
|
|
1560
|
+
let plan = args.includes("--pre-push") ? prePushPlan(fullPlan) : fullPlan;
|
|
1028
1561
|
const jobs = Math.max(1, Number(o.env.RK_GATE_JOBS ?? 4) || 4);
|
|
1029
1562
|
const testJobs = Math.max(1, Number(o.env.RK_GATE_TEST_JOBS ?? jobs) || jobs);
|
|
1030
1563
|
|
|
@@ -1039,6 +1572,7 @@ export async function main(argv, io = {}) {
|
|
|
1039
1572
|
}
|
|
1040
1573
|
|
|
1041
1574
|
for (const n of plan.notes) o.out(`${tag} note: ${n}`);
|
|
1575
|
+
o.out(`${tag} executor: ${executor.kind}${executor.kind === "broker" ? ` (${cargoCommand.join(" ")})` : " (no broker on this host: cargo runs directly, through the same gates)"}`);
|
|
1042
1576
|
const quietCount = plan.scans.filter((s) => s.quiet === true).length;
|
|
1043
1577
|
o.out(`${tag} ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)}; compile scans ${jobs} at a time, tests ${testJobs} at a time${quietCount ? `, ${quietCount} quiet test scan(s) last under the broker's quiet lease` : ""}`);
|
|
1044
1578
|
let signalCode = 0;
|
|
@@ -1053,7 +1587,7 @@ export async function main(argv, io = {}) {
|
|
|
1053
1587
|
}
|
|
1054
1588
|
// The result cache keys on the gated commit's object ids; the pre-push check above made HEAD the pushed commit.
|
|
1055
1589
|
let cache = null;
|
|
1056
|
-
if (!args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
|
|
1590
|
+
if (!classAOnly && !args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
|
|
1057
1591
|
try {
|
|
1058
1592
|
const headSha = text(git("rev-parse", "HEAD")).trim();
|
|
1059
1593
|
const treeId = text(git("rev-parse", `${headSha}^{tree}`)).trim();
|
|
@@ -1080,8 +1614,8 @@ export async function main(argv, io = {}) {
|
|
|
1080
1614
|
}
|
|
1081
1615
|
} catch { cache = null; }
|
|
1082
1616
|
}
|
|
1083
|
-
const scanResult = await runScans(plan, {
|
|
1084
|
-
classes: ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
|
|
1617
|
+
const scanResult = await (o.runScans ?? runScans)(plan, {
|
|
1618
|
+
classes: classAOnly ? ["A"] : ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
|
|
1085
1619
|
quietLane: quietLeaseLane(o, { tag, reason: `rightkit-gate-quiet-tests@${text(git("rev-parse", "--short", "HEAD")).trim()}` }),
|
|
1086
1620
|
onResult: (row, p) => o.out(`${tag} [${p.done}/${p.total}] ${(row.cached ? "cached" : row.status).padEnd(7)} ${row.id} (${p.seconds}s)${row.status === "fail" ? ` :: ${String(row.detail ?? "").slice(0, 300)}` : ""}`),
|
|
1087
1621
|
});
|
|
@@ -1099,9 +1633,13 @@ export async function main(argv, io = {}) {
|
|
|
1099
1633
|
if (failures.length) {
|
|
1100
1634
|
o.err(`${tag} BLOCKED (${failures.length})`);
|
|
1101
1635
|
for (const f of failures) o.err(` - ${f}`);
|
|
1102
|
-
o.err("Fix and push again. Never bypass with --no-verify.");
|
|
1636
|
+
o.err(classAOnly ? "Class A is red: nothing compiles until it is green." : "Fix and push again. Never bypass with --no-verify.");
|
|
1103
1637
|
return 1;
|
|
1104
1638
|
}
|
|
1639
|
+
if (classAOnly) {
|
|
1640
|
+
o.out(`${tag} class A pass: ${scanResult.scans.length} deterministic scan(s) over ${changed.length} file(s) of ${base}`);
|
|
1641
|
+
return 0;
|
|
1642
|
+
}
|
|
1105
1643
|
o.out(`${tag} pass (${changed.length} changed file(s) vs ${base}); ${deferrals.length} scan(s) deferred, not passed${scanResult.cacheHits.length ? `; ${scanResult.cacheHits.length} reused from the result cache` : ""}`);
|
|
1106
1644
|
return 0;
|
|
1107
1645
|
} finally {
|