@rightkit/release 0.6.4 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -7
- package/admission-files.mjs +279 -0
- package/admission-host.mjs +403 -0
- package/admission.mjs +146 -0
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +1326 -0
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +495 -69
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +87 -26
- package/cache-command.mjs +4 -3
- package/cargo-license-notices.mjs +2 -1
- package/cargo-manifest-text.mjs +25 -0
- package/cargo-profile-contract.mjs +26 -13
- package/cli/right-release.mjs +45 -5
- package/command-spec.mjs +4 -5
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +301 -37
- package/development.mjs +5 -5
- package/github-release.mjs +5 -3
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +134 -8
- package/inventory-check.mjs +12 -1
- package/ios-upload.mjs +4 -4
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -0
- package/ownership-dependency-allowlist.json +2 -0
- package/package.json +41 -3
- package/pipeline-admission.mjs +12 -0
- package/prebuild-gates.mjs +7 -3
- package/publish-cargo.mjs +215 -3
- package/publish-update.mjs +5 -3
- package/release-config.mjs +36 -5
- package/release-dependencies-command.mjs +4 -3
- package/release-qa-audit.mjs +12 -10
- package/release-qa-command.mjs +42 -7
- package/release-qa-hardening.mjs +10 -3
- package/release-qa-policy.mjs +214 -47
- package/release.mjs +117 -62
- package/rightkit-registry-resolver.mjs +5 -2
- package/rightkit-versions.json +18 -13
- package/stage-sidecar-command.mjs +5 -5
- package/test-command.mjs +3 -2
- package/upload-release.mjs +3 -2
- package/web-build.mjs +2 -1
package/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
|
|
@@ -24,14 +27,30 @@
|
|
|
24
27
|
// rightkit-gate [--base <ref>] [--plan] [--no-cache]
|
|
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
|
|
30
|
+
// rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
|
|
31
|
+
// the offline app check (../app-check.mjs): committed files only, no network, compile or
|
|
32
|
+
// broker, every finding in one run. Exit 0 pass, 1 findings, 2 could not run
|
|
27
33
|
// rightkit-gate install-hook [--force] write .githooks/pre-push and set core.hooksPath. core.hooksPath hides .git/hooks, so the
|
|
28
|
-
// pre-push hook runs the repository's own .git/hooks/pre-push first
|
|
29
|
-
// present in .git/hooks gets a
|
|
30
|
-
//
|
|
34
|
+
// pre-push hook runs the repository's own .git/hooks/pre-push first, then `rightkit-gate
|
|
35
|
+
// check --commit HEAD`, then the gate; every other hook present in .git/hooks gets a
|
|
36
|
+
// .githooks/<name> trampoline that runs it; the hook finds the gate binary in the root
|
|
37
|
+
// node_modules/.bin, then any workspace package's, then pnpm exec. Where @rightkit/release is
|
|
38
|
+
// installed it also writes the advisory ownership pre-commit step (`right-release hooks install`
|
|
39
|
+
// is an alias of this command)
|
|
31
40
|
//
|
|
32
41
|
// From the pre-push hook, git's ref lines arrive on stdin: the commit being pushed must be the checked-out HEAD (broker scans
|
|
33
42
|
// read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
|
|
34
43
|
// check it out first (a detached HEAD is fine), push HEAD:<branch>, return.
|
|
44
|
+
// A host with neither `rustright` nor the managed cargo shim (a cloud session) refuses the Rust gate, with one exception: when git's
|
|
45
|
+
// stdin names only non-protected branches (not main, master, trunk, release/*, the .rightgit.json defaultBranch, or a tag) and the
|
|
46
|
+
// process is not in CI (no CI or GITHUB_ACTIONS), the gate defers the Rust gate to host qualification. It reads the Rust units from the
|
|
47
|
+
// tracked manifests (no cargo), runs every scan that needs no broker, prints "rust gate DEFERRED to host qualification" with the Rust
|
|
48
|
+
// units and scans it did not run, exits 0 and writes nothing into the tree. An npm test scan whose package script chain runs `rustright` or
|
|
49
|
+
// `cargo` (planScans marks it `brokerCall`) cannot run there either and is deferred the same way, named with its reason. The pull request then
|
|
50
|
+
// needs a HOST RECEIPT before merge; that receipt stays the merge gate. RIGHTKIT_GATE_NO_DEFER=1 keeps the refusal on such a host. Every pushed ref is judged, whatever its place
|
|
51
|
+
// in the push; git's stdin is split on single ASCII spaces (a ref name may hold Unicode whitespace) and a line that is not four fields defers
|
|
52
|
+
// nothing. The default branch is named by the checkout, the base ref, the remote commits being replaced and origin/HEAD, so a commit cannot
|
|
53
|
+
// unprotect it by editing .rightgit.json. A refusal prints the old message and, on a second line, why nothing was deferred.
|
|
35
54
|
// ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it.
|
|
36
55
|
// Result cache: a passing class B scan is reused only when its scan id, command, tool version, environment and input digest
|
|
37
56
|
// (the gated commit's git object ids for the crate's workspace closure, the shared build inputs, ci/gate.config.json and every
|
|
@@ -47,7 +66,7 @@ import fs from "node:fs";
|
|
|
47
66
|
import os from "node:os";
|
|
48
67
|
import path from "node:path";
|
|
49
68
|
import { fileURLToPath } from "node:url";
|
|
50
|
-
import { WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, digestScanSet, fileScanCache, metadataArgs, planScans, runScans, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
|
|
69
|
+
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";
|
|
51
70
|
import { changedLockNames, parseLock, reach } from "../lock-graph.mjs";
|
|
52
71
|
|
|
53
72
|
const ZERO = /^0+$/;
|
|
@@ -58,7 +77,7 @@ const text = (r) => String(r?.stdout ?? "");
|
|
|
58
77
|
const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
|
|
59
78
|
const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
|
|
60
79
|
|
|
61
|
-
const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | --version`;
|
|
80
|
+
const USAGE = `usage: 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`;
|
|
62
81
|
|
|
63
82
|
// ---- pure helpers (exported for tests) ----------------------------------------------------------------------
|
|
64
83
|
|
|
@@ -109,31 +128,77 @@ const unitName = (pkgName, dir) => {
|
|
|
109
128
|
return SAFE_NAME.test(n) ? n : dir === "." ? "root" : dir.replace(/\//g, "-");
|
|
110
129
|
};
|
|
111
130
|
|
|
131
|
+
// 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
|
|
132
|
+
// some of them would hide the rest.
|
|
133
|
+
const PNPM_ROOT_MAX_DEPTH = 6;
|
|
134
|
+
const PNPM_ROOT_MAX_COUNT = 64;
|
|
135
|
+
|
|
136
|
+
/** Tracked files with exactly this name (a lister that ignores the name is filtered here). */
|
|
137
|
+
const trackedNamed = (tracked, name) => tracked(name).map(posix).filter((p) => path.posix.basename(p) === name);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The pnpm roots of the repository, sorted: "." when the repository root holds a pnpm-workspace.yaml or a package.json, and every other
|
|
141
|
+
* tracked directory (outside node_modules, at most 6 levels deep) that holds a pnpm-lock.yaml or a pnpm-workspace.yaml beside a
|
|
142
|
+
* package.json. A repository such as CodeRight (apps/coderight-tauri and apps/coderight-web, each with its own lock, nothing at the root) has
|
|
143
|
+
* 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.
|
|
144
|
+
*/
|
|
145
|
+
export function discoverPnpmRoots({ root, tracked, exists }) {
|
|
146
|
+
const manifestDirs = new Set(trackedNamed(tracked, "package.json").map((p) => path.posix.dirname(p)));
|
|
147
|
+
const roots = new Set();
|
|
148
|
+
if (exists(path.join(root, "pnpm-workspace.yaml")) || exists(path.join(root, "package.json"))) roots.add(".");
|
|
149
|
+
for (const file of [...trackedNamed(tracked, "pnpm-lock.yaml"), ...trackedNamed(tracked, "pnpm-workspace.yaml")]) {
|
|
150
|
+
const dir = path.posix.dirname(file);
|
|
151
|
+
const parts = dir.split("/");
|
|
152
|
+
if (dir === "." || parts.includes("node_modules") || parts.length > PNPM_ROOT_MAX_DEPTH || !manifestDirs.has(dir)) continue;
|
|
153
|
+
roots.add(dir);
|
|
154
|
+
}
|
|
155
|
+
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)`);
|
|
156
|
+
return [...roots].sort();
|
|
157
|
+
}
|
|
158
|
+
|
|
112
159
|
/**
|
|
113
|
-
* npm units: pnpm-workspace.yaml globs over tracked package.json files, else the root
|
|
114
|
-
* unit as well when the workspace file is absent, has no `packages:` (or an empty one) or declares
|
|
115
|
-
* `packages:` list that does not name the root it is only the workspace's own manifest and is not a unit.
|
|
160
|
+
* npm units, per pnpm root (discoverPnpmRoots). Within a root: pnpm-workspace.yaml globs over the tracked package.json files, else the root's
|
|
161
|
+
* 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
|
|
162
|
+
* "." (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
|
|
163
|
+
* belongs to the deepest root that holds it. The repository root's units are named from their package names (`@scope/x` -> `scope-x`, a
|
|
164
|
+
* 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
|
|
165
|
+
* from their directory (`apps/coderight-web` -> `apps-coderight-web`), so their scan ids carry the root path, and carry `root`.
|
|
116
166
|
*/
|
|
117
167
|
export function discoverNpmUnits({ root, tracked, readFile, exists }) {
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
168
|
+
const roots = discoverPnpmRoots({ root, tracked, exists });
|
|
169
|
+
const manifestDirs = [...new Set(trackedNamed(tracked, "package.json").map((p) => path.posix.dirname(p)))];
|
|
170
|
+
const within = (rootDir, dir) => (rootDir === "." ? dir : dir === rootDir ? "." : dir.startsWith(`${rootDir}/`) ? dir.slice(rootDir.length + 1) : null);
|
|
171
|
+
const joined = (rootDir, rel) => (rootDir === "." ? rel : rel === "." ? rootDir : `${rootDir}/${rel}`);
|
|
172
|
+
const ownerOf = (dir) => roots.reduce((best, r) => (within(r, dir) !== null && (best === null || r.length > best.length) ? r : best), null);
|
|
173
|
+
const found = new Map();
|
|
174
|
+
for (const rootDir of roots) {
|
|
175
|
+
let dirs;
|
|
176
|
+
const workspaceFile = path.join(root, joined(rootDir, "pnpm-workspace.yaml"));
|
|
177
|
+
if (exists(workspaceFile)) {
|
|
178
|
+
const globs = parseWorkspaceGlobs(readFile(workspaceFile));
|
|
179
|
+
const inc = globs.filter((g) => !g.startsWith("!")).map(globToRegExp);
|
|
180
|
+
const exc = globs.filter((g) => g.startsWith("!")).map((g) => globToRegExp(g.slice(1)));
|
|
181
|
+
const positive = globs.filter((g) => !g.startsWith("!"));
|
|
182
|
+
const rootDeclared = positive.length === 0 || positive.some((g) => g === "." || g === "./");
|
|
183
|
+
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));
|
|
184
|
+
} else dirs = exists(path.join(root, joined(rootDir, "package.json"))) ? [rootDir] : [];
|
|
185
|
+
for (const dir of dirs) if (ownerOf(dir) === rootDir) found.set(dir, rootDir);
|
|
186
|
+
}
|
|
187
|
+
const rows = [...found.keys()].sort().map((dir) => {
|
|
130
188
|
let manifest;
|
|
131
189
|
try { manifest = JSON.parse(readFile(path.join(root, dir, "package.json")).replace(/^/, "")); } catch (e) { throw new Error(`${dir}/package.json: ${e.message}`); }
|
|
132
|
-
|
|
190
|
+
const rootDir = found.get(dir);
|
|
191
|
+
return { dir, root: rootDir, name: rootDir === "." ? unitName(manifest.name, dir) : dir.replace(/\//g, "-") };
|
|
133
192
|
});
|
|
134
193
|
const count = new Map();
|
|
135
194
|
for (const r of rows) count.set(r.name, (count.get(r.name) ?? 0) + 1);
|
|
136
|
-
|
|
195
|
+
const named = rows.map((r) => ({ ...r, name: count.get(r.name) > 1 && r.root === "." ? unitName("", r.dir === "." ? "root" : r.dir) : r.name }));
|
|
196
|
+
const taken = new Set();
|
|
197
|
+
for (const r of named) {
|
|
198
|
+
if (taken.has(r.name)) throw new Error(`two npm units are named ${r.name} (${r.dir}); rename one of the packages`);
|
|
199
|
+
taken.add(r.name);
|
|
200
|
+
}
|
|
201
|
+
return named.map((r) => ({ name: r.name, dir: r.dir, ...(r.root === "." ? {} : { root: r.root }) }));
|
|
137
202
|
}
|
|
138
203
|
|
|
139
204
|
const dirOfManifest = (m) => path.posix.dirname(m);
|
|
@@ -199,13 +264,56 @@ export function manifestTables(text) {
|
|
|
199
264
|
return tables;
|
|
200
265
|
}
|
|
201
266
|
|
|
267
|
+
/**
|
|
268
|
+
* The value text of `key` in the top-level table `table` of a manifest read by manifestTables (everything from the `=` on), whether it is
|
|
269
|
+
* written under `[table]` or as the dotted root key `table.key = ...`; null when absent. No value parsing: tomlString and tomlStrings read it.
|
|
270
|
+
*/
|
|
271
|
+
export function tomlValue(tables, table, key) {
|
|
272
|
+
const esc = (x) => x.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
273
|
+
const own = tables.get(table) ?? "";
|
|
274
|
+
const inTable = new RegExp(`^[ \\t]*${esc(key)}[ \\t]*=[ \\t]*`, "m").exec(own);
|
|
275
|
+
if (inTable) return own.slice(inTable.index + inTable[0].length);
|
|
276
|
+
const root = tables.get("preamble") ?? "";
|
|
277
|
+
const dotted = new RegExp(`^[ \\t]*${esc(table)}\\.${esc(key)}[ \\t]*=[ \\t]*`, "m").exec(root);
|
|
278
|
+
return dotted ? root.slice(dotted.index + dotted[0].length) : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** True when the manifest has the top-level table `name`: as `[name]`, as a `[name.sub]` table, or as dotted root keys `name.key = ...`. */
|
|
282
|
+
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") ?? "");
|
|
283
|
+
|
|
284
|
+
/** The string a TOML value starts with ("basic" or 'literal'); null when the value is not a string. */
|
|
285
|
+
export function tomlString(value) {
|
|
286
|
+
const m = /^(?:"((?:[^"\\\n]|\\.)*)"|'([^'\n]*)')/.exec(String(value ?? ""));
|
|
287
|
+
return m ? (m[1] !== undefined ? m[1].replace(/\\(.)/g, "$1") : m[2]) : null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The strings of the TOML array a value starts with: "basic" and 'literal' strings, across lines, with `#` comments (and any `]` in
|
|
292
|
+
* them or in a string) skipped. [] when the value is not an array.
|
|
293
|
+
*/
|
|
294
|
+
export function tomlStrings(value) {
|
|
295
|
+
const s = String(value ?? "");
|
|
296
|
+
if (s[0] !== "[") return [];
|
|
297
|
+
const out = [];
|
|
298
|
+
for (let i = 1; i < s.length;) {
|
|
299
|
+
const c = s[i];
|
|
300
|
+
if (c === "]") break;
|
|
301
|
+
if (c === "#") { while (i < s.length && s[i] !== "\n") i += 1; continue; }
|
|
302
|
+
if (c === '"' || c === "'") {
|
|
303
|
+
let j = i + 1; let v = "";
|
|
304
|
+
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; } }
|
|
305
|
+
out.push(v); i = j + 1; continue;
|
|
306
|
+
}
|
|
307
|
+
i += 1;
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
202
312
|
/** The repository-relative directories a workspace manifest `exclude`s (`[workspace] exclude = [...]`); [] when none or unreadable. */
|
|
203
313
|
export function workspaceExcludes(readFile, root, cargoDir) {
|
|
204
314
|
let text;
|
|
205
315
|
try { text = readFile(path.join(root, cargoDir === "." ? "Cargo.toml" : `${cargoDir}/Cargo.toml`)); } catch { return []; }
|
|
206
|
-
|
|
207
|
-
const list = /^\s*exclude\s*=\s*\[([\s\S]*?)\]/m.exec(body)?.[1] ?? "";
|
|
208
|
-
return [...list.matchAll(/"([^"]+)"/g)].map((m) => posix(m[1]).replace(/\/+$/, "")).map((d) => (cargoDir === "." ? d : `${cargoDir}/${d}`));
|
|
316
|
+
return tomlStrings(tomlValue(manifestTables(text), "workspace", "exclude")).map((d) => posix(d).replace(/\/+$/, "")).map((d) => (cargoDir === "." ? d : `${cargoDir}/${d}`));
|
|
209
317
|
}
|
|
210
318
|
|
|
211
319
|
// The manifest tables whose every effect on a build is also written into Cargo.lock: a changed source ([patch.*]) and workspace
|
|
@@ -248,8 +356,10 @@ export function manifestChangeIsLockReflected(before, after) {
|
|
|
248
356
|
* A member whose Cargo.toml is not tracked (untracked or ignored) is dropped into `skipped`, as are `untracked()` manifests.
|
|
249
357
|
* Returns { present, workspaces: [{ cargoDir, units }], units, cargoDir, skipped: [{ manifest, why }] }; `units` is every workspace's
|
|
250
358
|
* [{ name, crate, dir, publish, cargoDir }] and `cargoDir` the first workspace's (the repository root's when it has one).
|
|
359
|
+
* `metadata(dir)` (optional) replaces the cargo call with a reader of the same { cargoDir, members } shape (extra fields are ignored): the
|
|
360
|
+
* deferred pre-push gate passes manifestWorkspaceMetadata where no broker can run cargo.
|
|
251
361
|
*/
|
|
252
|
-
export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8") }) {
|
|
362
|
+
export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8"), metadata = null }) {
|
|
253
363
|
const manifests = tracked("Cargo.toml");
|
|
254
364
|
const trackedSet = new Set(manifests);
|
|
255
365
|
const locks = new Set(tracked("Cargo.lock"));
|
|
@@ -277,7 +387,7 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
|
|
|
277
387
|
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; }
|
|
278
388
|
}
|
|
279
389
|
let found;
|
|
280
|
-
try { found = workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
|
|
390
|
+
try { found = metadata ? metadata(dir) : workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
|
|
281
391
|
if (!parent) throw e;
|
|
282
392
|
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 [^)]*\)$/, "")})` });
|
|
283
393
|
continue;
|
|
@@ -300,6 +410,212 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
|
|
|
300
410
|
return { present: true, workspaces, units, cargoDir: workspaces[0]?.cargoDir ?? ".", skipped };
|
|
301
411
|
}
|
|
302
412
|
|
|
413
|
+
// ---- the Rust gate on a host without the broker: deferral to host qualification --------------------------------------
|
|
414
|
+
|
|
415
|
+
// A push that only carries non-protected branches, from a host that has neither `rustright` nor the managed cargo shim and is not
|
|
416
|
+
// CI, does not refuse on the missing broker: the Rust scans cannot run here, so the gate runs every other scan, names the Rust
|
|
417
|
+
// units and scans it did not run, and exits 0. The pull request then needs a HOST RECEIPT (a host with the broker ran the full gate)
|
|
418
|
+
// before it merges; that receipt, not this hook, is the merge gate. Everything else keeps today's behaviour: a push that includes a
|
|
419
|
+
// protected branch, a host with the broker or the shim, CI, and RIGHTKIT_GATE_NO_DEFER=1 all refuse as before.
|
|
420
|
+
|
|
421
|
+
/** Branches that never defer (compared case-insensitively); `release/*` and the repository's default branch join them. */
|
|
422
|
+
export const PROTECTED_BRANCHES = Object.freeze(["main", "master", "trunk"]);
|
|
423
|
+
/** Set to anything but ""/0/false to keep the refusal on a broker-less host. */
|
|
424
|
+
export const DEFER_OPT_OUT_ENV = "RIGHTKIT_GATE_NO_DEFER";
|
|
425
|
+
|
|
426
|
+
const envOn = (v) => v !== undefined && v !== null && !["", "0", "false"].includes(String(v).trim().toLowerCase());
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* True when `ref` (a pushed remote ref) is a plain branch the gate may defer for. A tag, a ref outside refs/heads/, main, master,
|
|
430
|
+
* trunk, release/* and the `extra` names (the repository's own default branch, `.rightgit.json` defaultBranch) are not.
|
|
431
|
+
*/
|
|
432
|
+
export function isDeferrableRef(ref, extra = []) {
|
|
433
|
+
const m = /^refs\/heads\/(.+)$/.exec(String(ref ?? ""));
|
|
434
|
+
if (!m) return false;
|
|
435
|
+
const name = m[1].toLowerCase();
|
|
436
|
+
return !(PROTECTED_BRANCHES.includes(name) || extra.some((x) => String(x).toLowerCase() === name) || name.startsWith("release/"));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Whether the Rust gate defers to host qualification. `refs` are the remote refs of a git pre-push's stdin (deletions included); an
|
|
441
|
+
* empty list is not a pre-push run. `unparsed` are the stdin lines parsePushLines could not read as four fields: a push whose refs
|
|
442
|
+
* cannot all be read defers nothing (main refuses such a push before it gets here, on every host; this is the second lock). Every
|
|
443
|
+
* pushed ref is judged, whatever its position. Returns { defer, why }. The caller asks only when no broker or shim resolved.
|
|
444
|
+
*/
|
|
445
|
+
export function rustDeferral({ refs = [], env = {}, protectedBranches = [], unparsed = [] }) {
|
|
446
|
+
if (envOn(env[DEFER_OPT_OUT_ENV])) return { defer: false, why: `${DEFER_OPT_OUT_ENV} is set` };
|
|
447
|
+
if (envOn(env.CI) || envOn(env.GITHUB_ACTIONS)) return { defer: false, why: "running in CI" };
|
|
448
|
+
if (unparsed.length) return { defer: false, why: `pre-push stdin line ${unparsed[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` };
|
|
449
|
+
if (!refs.length) return { defer: false, why: "no pushed refs on stdin: not a git pre-push run" };
|
|
450
|
+
const blocking = refs.find((r) => !isDeferrableRef(r, protectedBranches));
|
|
451
|
+
if (blocking) return { defer: false, why: `${blocking} is not a plain non-protected branch (main, master, trunk, release/*, the repository's default branch and tags are never deferred)` };
|
|
452
|
+
return { defer: true, why: `only non-protected branches are pushed (${refs.join(", ")}) and this is not CI` };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* git's pre-push stdin: one "<local ref> <local sha> <remote ref> <remote sha>" per line, the four fields joined by single ASCII spaces.
|
|
457
|
+
* 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
|
|
458
|
+
* 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
|
|
459
|
+
* four non-empty fields is returned in `malformed` ({ number, line }), never dropped. Returns { lines: [{ localRef, localSha, remoteRef,
|
|
460
|
+
* remoteSha }], malformed }.
|
|
461
|
+
*/
|
|
462
|
+
export function parsePushLines(stdin) {
|
|
463
|
+
const lines = []; const malformed = [];
|
|
464
|
+
String(stdin ?? "").split("\n").forEach((raw, index) => {
|
|
465
|
+
const line = raw.replace(/\r$/, "");
|
|
466
|
+
if (line === "") return;
|
|
467
|
+
const f = line.split(" ");
|
|
468
|
+
if (f.length !== 4 || f.some((x) => x === "")) { malformed.push({ number: index + 1, line }); return; }
|
|
469
|
+
lines.push({ localRef: f[0], localSha: f[1], remoteRef: f[2], remoteSha: f[3] });
|
|
470
|
+
});
|
|
471
|
+
return { lines, malformed };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const rightgitDefaultBranch = (raw) => { try { const d = JSON.parse(String(raw).replace(/^\uFEFF/, "")).defaultBranch; return typeof d === "string" && d ? d : null; } catch { return null; } };
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The names this repository calls its default branch, read from every side a push can be judged by, so a commit that edits
|
|
478
|
+
* `.rightgit.json` cannot unprotect the branch it pushes: the manifest in the checkout, the manifest at origin/HEAD and at each of
|
|
479
|
+
* `revisions` (the base ref and the remote commits being replaced), and the branch origin/HEAD names. `git(...args)` answers like
|
|
480
|
+
* spawnSync; a revision git cannot show contributes nothing. Residual, not closable offline: with no origin/HEAD, no manifest at the base
|
|
481
|
+
* and a replaced remote commit that is not in the local object store (a force-push whose tip was never fetched), the only manifest left
|
|
482
|
+
* is the checkout's, which the pushed commit supplies. The union only ever adds names, so a local branch or tag called `origin/HEAD`
|
|
483
|
+
* cannot remove one: the unambiguous `refs/remotes/origin/HEAD` symbolic-ref read still names the real branch.
|
|
484
|
+
*/
|
|
485
|
+
export function defaultBranchNames({ git, readCheckout, revisions = [] }) {
|
|
486
|
+
const names = new Set();
|
|
487
|
+
const add = (n) => { if (n) names.add(n); };
|
|
488
|
+
try { add(rightgitDefaultBranch(readCheckout())); } catch { /* no right-git manifest in the checkout */ }
|
|
489
|
+
// A revision that starts with "-" would be read by git as an option, not a revision.
|
|
490
|
+
for (const rev of new Set(["origin/HEAD", ...revisions.filter((r) => typeof r === "string" && r && !r.startsWith("-"))])) {
|
|
491
|
+
const shown = git("show", `${rev}:.rightgit.json`);
|
|
492
|
+
if (!failed(shown)) add(rightgitDefaultBranch(text(shown)));
|
|
493
|
+
}
|
|
494
|
+
const head = git("symbolic-ref", "--quiet", "refs/remotes/origin/HEAD");
|
|
495
|
+
if (!failed(head)) add(/^refs\/remotes\/origin\/(.+)$/.exec(text(head).trim())?.[1]);
|
|
496
|
+
return [...names];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The path dependencies a manifest declares: `path = "..."` in a [dependencies], [dev-dependencies], [build-dependencies],
|
|
500
|
+
// [target.*.dependencies] or [workspace.dependencies] table, inline (`foo = { path = "../foo" }`) or under `[dependencies.foo]`.
|
|
501
|
+
function pathDependencies(tables) {
|
|
502
|
+
const out = [];
|
|
503
|
+
for (const [name, body] of tables) {
|
|
504
|
+
if (!/(?:^|\.)(?:dev-|build-)?dependencies(?:\.|$)/.test(name)) continue;
|
|
505
|
+
for (const m of body.matchAll(/(?:^|[\s{,.])path[ \t]*=[ \t]*(?:"((?:[^"\\\n]|\\.)*)"|'([^'\n]*)')/gm)) out.push(m[1] ?? m[2]);
|
|
506
|
+
}
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* A cargo-free stand-in for `cargo metadata --no-deps` in one workspace directory, read from the tracked manifests: the same
|
|
512
|
+
* { cargoDir, members } as workspaceMetadata. A workspace root lists its `[package]` and every tracked manifest below it that its
|
|
513
|
+
* `members` globs name and its `exclude` does not, then (as cargo does) every tracked package below the root that a member, or the root's
|
|
514
|
+
* [workspace.dependencies], reaches by a path dependency and `exclude` does not name. A manifest with no `[workspace]` is a standalone
|
|
515
|
+
* package unless an ancestor workspace owns that directory (cargo refuses it there, so this throws and discoverRustWorkspaces skips it).
|
|
516
|
+
* Strings may be "basic" or 'literal', arrays may carry comments, and `package.name = ...` / `workspace.members = [...]` dotted keys read like
|
|
517
|
+
* their tables. It is a reader, not a TOML parser: a manifest a glob or a path dependency reaches but whose package name it cannot read is
|
|
518
|
+
* returned in `unplaced` (beside { cargoDir, members }; empty for a standalone package, which has nothing to place), any other it cannot place
|
|
519
|
+
* is reported by unmatchedManifests, and the host receipt runs the real metadata.
|
|
520
|
+
*/
|
|
521
|
+
export function manifestWorkspaceMetadata({ root, dir, tracked, readFile }) {
|
|
522
|
+
const manifestOf = (d) => (d === "." ? "Cargo.toml" : `${d}/Cargo.toml`);
|
|
523
|
+
const cache = new Map();
|
|
524
|
+
const read = (m) => { if (!cache.has(m)) cache.set(m, manifestTables(readFile(path.join(root, m)))); return cache.get(m); };
|
|
525
|
+
const hasPackage = (m) => hasTomlTable(read(m), "package");
|
|
526
|
+
const memberOf = (m) => {
|
|
527
|
+
const tables = read(m);
|
|
528
|
+
const name = tomlString(tomlValue(tables, "package", "name"));
|
|
529
|
+
if (!name) return null;
|
|
530
|
+
const publish = tomlValue(tables, "package", "publish");
|
|
531
|
+
return { name, dir: dirOfManifest(m), manifest: m, publish: !(publish !== null && /^(?:false\b|\[\s*\])/.test(publish)) };
|
|
532
|
+
};
|
|
533
|
+
const all = tracked("Cargo.toml");
|
|
534
|
+
const tables = read(manifestOf(dir));
|
|
535
|
+
const byName = (a, b) => (a.name < b.name ? -1 : 1);
|
|
536
|
+
if (!hasTomlTable(tables, "workspace")) {
|
|
537
|
+
for (let up = dir; up !== "."; ) {
|
|
538
|
+
up = path.posix.dirname(up);
|
|
539
|
+
if (all.includes(manifestOf(up)) && hasTomlTable(read(manifestOf(up)), "workspace")) throw new Error(`current package believes it's in a workspace when it's not (the workspace root is ${up === "." ? "the repository root" : up})`);
|
|
540
|
+
}
|
|
541
|
+
const self = memberOf(manifestOf(dir));
|
|
542
|
+
if (!self) throw new Error(`${manifestOf(dir)} has neither a [package] with a name nor a [workspace]`);
|
|
543
|
+
return { cargoDir: dir, members: [self], unplaced: [] };
|
|
544
|
+
}
|
|
545
|
+
const patterns = tomlStrings(tomlValue(tables, "workspace", "members")).map((g) => globToRegExp(g));
|
|
546
|
+
const excluded = workspaceExcludes(readFile, root, dir);
|
|
547
|
+
const isExcluded = (d) => excluded.some((x) => d === x || d.startsWith(`${x}/`));
|
|
548
|
+
const rel = (d) => (d === dir ? "." : dir === "." ? d : d.startsWith(`${dir}/`) ? d.slice(dir.length + 1) : null);
|
|
549
|
+
const found = new Map(); const unplaced = new Set();
|
|
550
|
+
const add = (m) => {
|
|
551
|
+
if (found.has(m)) return false;
|
|
552
|
+
const member = memberOf(m);
|
|
553
|
+
if (member) found.set(m, member); else unplaced.add(m);
|
|
554
|
+
return Boolean(member);
|
|
555
|
+
};
|
|
556
|
+
for (const m of all) {
|
|
557
|
+
const d = dirOfManifest(m);
|
|
558
|
+
const r = rel(d);
|
|
559
|
+
if (r === null || r === "." || isExcluded(d) || !patterns.some((re) => re.test(r))) continue;
|
|
560
|
+
add(m);
|
|
561
|
+
}
|
|
562
|
+
if (hasPackage(manifestOf(dir))) add(manifestOf(dir));
|
|
563
|
+
// Path dependencies below the workspace root become members on their own, transitively.
|
|
564
|
+
const queue = [manifestOf(dir), ...found.keys()];
|
|
565
|
+
for (let m = queue.pop(); m !== undefined; m = queue.pop()) {
|
|
566
|
+
for (const p of pathDependencies(read(m))) {
|
|
567
|
+
const target = path.posix.normalize(path.posix.join(dirOfManifest(m), p)).replace(/\/$/, "");
|
|
568
|
+
const tm = manifestOf(target);
|
|
569
|
+
if (rel(target) === null || rel(target) === "." || isExcluded(target) || !all.includes(tm) || found.has(tm) || !hasPackage(tm)) continue;
|
|
570
|
+
if (add(tm)) queue.push(tm);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return { cargoDir: dir, members: [...found.values()].sort(byName), unplaced: [...unplaced].sort() };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The tracked Cargo.toml with a package that no unit covers and discoverRustWorkspaces did not skip: what the manifest reader could
|
|
578
|
+
* not place (an unreadable name, a shape it does not know). A manifest it cannot read at all is listed too. Sorted.
|
|
579
|
+
*/
|
|
580
|
+
export function unmatchedManifests({ root, manifests, rust, readFile }) {
|
|
581
|
+
const covered = new Set([...rust.units.map((u) => (u.dir === "." ? "Cargo.toml" : `${u.dir}/Cargo.toml`)), ...rust.skipped.map((k) => k.manifest)]);
|
|
582
|
+
return manifests.filter((m) => {
|
|
583
|
+
if (covered.has(m)) return false;
|
|
584
|
+
try { return hasTomlTable(manifestTables(readFile(path.join(root, m))), "package"); } catch { return true; }
|
|
585
|
+
}).sort();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** `plan` without the scans in `dropped` (by id), its digests recomputed over what is left, and `note` appended. */
|
|
589
|
+
export function withoutScans(plan, dropped, note) {
|
|
590
|
+
const gone = new Set(dropped.map((s) => s.id));
|
|
591
|
+
const scans = plan.scans.filter((s) => !gone.has(s.id));
|
|
592
|
+
return {
|
|
593
|
+
...plan, scans, deferred: plan.deferred.filter((id) => !gone.has(id)),
|
|
594
|
+
scanSetDigest: digestScanSet(scans),
|
|
595
|
+
classDigests: { A: digestScanSet(scans.filter((s) => s.class === "A")), B: digestScanSet(scans.filter((s) => s.class === "B")) },
|
|
596
|
+
notes: note ? [...plan.notes, note] : plan.notes,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The lines the gate prints when it defers: the Rust units and scans that did not run, the pushed refs, and who gates the merge.
|
|
602
|
+
* `manifests` is the number of tracked Cargo.toml: when it is above zero and no unit was found, the block says the reader found none
|
|
603
|
+
* and that the scans cannot be named, instead of claiming no Rust scan was due. `unmatched` lists the tracked manifests with a package
|
|
604
|
+
* that no unit covers (unmatchedManifests); they get a line of their own. `npmCalls` are the npm test scans deferred with the Rust
|
|
605
|
+
* gate because their package script runs `rustright` or `cargo` ([{ id, tool, via, command }]); `notRun` names them too, and a line of their
|
|
606
|
+
* own gives each one's reason.
|
|
607
|
+
*/
|
|
608
|
+
export function deferredBlock({ units, notRun, refs, manifests = 0, unmatched = [], tag = "rightkit-gate:", npmCalls = [] }) {
|
|
609
|
+
const noUnits = units.length === 0 && manifests > 0;
|
|
610
|
+
const lines = [
|
|
611
|
+
`${tag} rust gate DEFERRED to host qualification (no RustRight broker on this host): rust units ${units.length ? units.join(", ") : noUnits ? `none found among the ${manifests} tracked Cargo.toml (read without cargo)` : "none found"}; scans not run ${notRun.length ? notRun.join(", ") : noUnits ? "unknown (no unit was found, so no Rust scan could be planned)" : "none (no Rust scan was due for this diff)"}; the pull request needs a HOST RECEIPT before merge`,
|
|
612
|
+
`${tag} pushed ${refs.join(", ")} (non-protected, not CI). The HOST RECEIPT (a host with the RustRight broker ran the full gate) stays the merge gate; ${DEFER_OPT_OUT_ENV}=1 keeps the refusal on this host.`,
|
|
613
|
+
];
|
|
614
|
+
if (unmatched.length) lines.push(`${tag} tracked Cargo.toml with a package that matched no unit (the manifest reader runs no cargo; the HOST RECEIPT does): ${unmatched.join(", ")}`);
|
|
615
|
+
if (npmCalls.length) lines.push(`${tag} npm test scan(s) deferred with the Rust gate, never run and never passed, because the package script runs ${[...new Set(npmCalls.map((c) => c.tool))].join(" or ")} and this host has no broker: ${npmCalls.map((c) => `${c.id} (script ${c.via.join(" -> ")}: ${c.command})`).join("; ")}`);
|
|
616
|
+
return lines;
|
|
617
|
+
}
|
|
618
|
+
|
|
303
619
|
/**
|
|
304
620
|
* The crates a lock-reflected workspace change reaches (the lock graph CI's selection uses): a Cargo.lock change, a vendor/ change
|
|
305
621
|
* (the vendored crate is a lock package), and a workspace manifest change confined to the tables the lock also records
|
|
@@ -340,13 +656,15 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
|
|
|
340
656
|
/**
|
|
341
657
|
* The pre-push hook. git's ref lines arrive on stdin and the gate consumes them, so stdin is buffered once and fed to both the
|
|
342
658
|
* repository's own .git/hooks/pre-push (which core.hooksPath=.githooks would otherwise hide; it runs first and a failure stops the
|
|
343
|
-
* push) and the gate.
|
|
344
|
-
*
|
|
345
|
-
*
|
|
659
|
+
* push) and the gate. Between them runs the app check, `rightkit-gate check --commit HEAD`: seconds, committed files only, so a
|
|
660
|
+
* red tree stops the push before the gate's scans start; a push that only deletes refs has no content to check and skips it. The
|
|
661
|
+
* gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three levels down: tauri-app-next,
|
|
662
|
+
* apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when pnpm is absent; the line it
|
|
663
|
+
* chose is printed once. No absolute path is written: everything is resolved from git at run time.
|
|
346
664
|
*/
|
|
347
665
|
export function hookText(nodePath) {
|
|
348
666
|
return `#!/bin/sh
|
|
349
|
-
# Mandatory local gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
|
|
667
|
+
# Mandatory local check and gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
|
|
350
668
|
cd "$(git rev-parse --show-toplevel)" || exit 1
|
|
351
669
|
stdin=$(mktemp "\${TMPDIR:-/tmp}/rightkit-gate.XXXXXX") || exit 1
|
|
352
670
|
trap 'rm -f "$stdin"' EXIT
|
|
@@ -358,18 +676,27 @@ if [ -x "$own" ]; then
|
|
|
358
676
|
"$own" "$@" < "$stdin" || exit $?
|
|
359
677
|
fi
|
|
360
678
|
# The gate binary: the root install, any workspace package's install, then pnpm exec.
|
|
679
|
+
found=""
|
|
361
680
|
for bin in node_modules/.bin/rightkit-gate */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
|
|
362
|
-
if [ -x "$bin" ]; then
|
|
363
|
-
echo "rightkit-gate: using $bin" >&2
|
|
364
|
-
("$bin" --base origin/main) < "$stdin"; exit $?
|
|
365
|
-
fi
|
|
681
|
+
if [ -x "$bin" ]; then found="$bin"; break; fi
|
|
366
682
|
done
|
|
367
|
-
if
|
|
683
|
+
if [ -n "$found" ]; then
|
|
684
|
+
echo "rightkit-gate: using $found" >&2
|
|
685
|
+
rightkit_gate() { "$found" "$@"; }
|
|
686
|
+
elif command -v pnpm >/dev/null 2>&1; then
|
|
368
687
|
echo "rightkit-gate: using pnpm exec rightkit-gate" >&2
|
|
369
|
-
(
|
|
688
|
+
rightkit_gate() { pnpm exec rightkit-gate "$@"; }
|
|
689
|
+
else
|
|
690
|
+
echo "rightkit-gate: using node ${nodePath}" >&2
|
|
691
|
+
rightkit_gate() { node "${nodePath}" "$@"; }
|
|
692
|
+
fi
|
|
693
|
+
# The check first, over the committed files of HEAD; a push that only deletes refs has nothing to check.
|
|
694
|
+
if [ -s "$stdin" ] && ! awk '$2 !~ /^0+$/ { found = 1 } END { exit !found }' "$stdin"; then
|
|
695
|
+
echo "rightkit-gate: only deletions are pushed; nothing to check" >&2
|
|
696
|
+
else
|
|
697
|
+
rightkit_gate check --commit HEAD < /dev/null >&2 || exit $?
|
|
370
698
|
fi
|
|
371
|
-
|
|
372
|
-
(exec node "${nodePath}" --base origin/main) < "$stdin"; exit $?
|
|
699
|
+
rightkit_gate --base origin/main < "$stdin"; exit $?
|
|
373
700
|
`;
|
|
374
701
|
}
|
|
375
702
|
|
|
@@ -551,7 +878,7 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
|
|
|
551
878
|
* when declared. Without a declaration, a closure source that navigates out of its crate at run time without a literal path
|
|
552
879
|
* (externalInputs `leavesCrate`: workspace root, current_dir, a bare "..", the manifest directory's parent) keys the scan on the
|
|
553
880
|
* whole HEAD tree, since its tests may read anything.
|
|
554
|
-
* - An npm package scan (test:npm, tsc, eslint) keys on the package's workspace closure (its `workspace:` dependencies,
|
|
881
|
+
* - An npm package scan (test:npm, tsc, eslint, biome) keys on the package's workspace closure (its `workspace:` dependencies,
|
|
555
882
|
* transitively, by package name) and the root npm inputs; test:scripts-ci on scripts/, ci/, .github/, packages/release and the
|
|
556
883
|
* root install graph. Every other class B scan (generated-docs, swift-build) uses the whole tree.
|
|
557
884
|
* - Class A: cargo-deny, cargo-audit, pnpm-audit, pnpm-lockfile and advisory-exceptions are never cached (live answers); rustfmt,
|
|
@@ -560,6 +887,9 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
|
|
|
560
887
|
*/
|
|
561
888
|
// Root npm inputs every package scan reads: the install graph and the shared tool configuration.
|
|
562
889
|
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"];
|
|
890
|
+
// ESLint and Biome look their configuration up from the package directory through every ancestor. A config in an ancestor directory (or of another
|
|
891
|
+
// 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.
|
|
892
|
+
const LINT_CONFIG_NAMES = [...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
|
|
563
893
|
// The gate's own test suite reads the CI scripts, the gate config and the release package it imports from.
|
|
564
894
|
const SCRIPTS_CI_INPUTS = ["scripts", "ci", ".github", "packages/release", "package.json", "pnpm-lock.yaml"];
|
|
565
895
|
|
|
@@ -580,7 +910,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
580
910
|
};
|
|
581
911
|
let npmByPkgName = null;
|
|
582
912
|
const npmClosure = (unit) => {
|
|
583
|
-
if (npmByPkgName === null) { npmByPkgName = new Map(); for (const u of npmUnits) { const m = npmManifest(u); if (m?.name) npmByPkgName.set(m.name
|
|
913
|
+
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); } }
|
|
584
914
|
const seen = new Map([[unit.name, unit]]); const stack = [unit]; let unresolved = false;
|
|
585
915
|
while (stack.length) {
|
|
586
916
|
const u = stack.pop(); const m = npmManifest(u);
|
|
@@ -588,7 +918,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
588
918
|
for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
|
|
589
919
|
for (const [name, spec] of Object.entries(m[section] ?? {})) {
|
|
590
920
|
if (!/^(workspace:|link:|file:)/.test(String(spec))) continue;
|
|
591
|
-
const dep = npmByPkgName.get(name);
|
|
921
|
+
const dep = npmByPkgName.get(`${u.root ?? "."}\0${name}`);
|
|
592
922
|
if (!dep) { unresolved = true; continue; }
|
|
593
923
|
if (!seen.has(dep.name)) { seen.set(dep.name, dep); stack.push(dep); }
|
|
594
924
|
}
|
|
@@ -596,6 +926,18 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
596
926
|
}
|
|
597
927
|
return { members: [...seen.values()].sort((a, b) => (a.name < b.name ? -1 : 1)), unresolved };
|
|
598
928
|
};
|
|
929
|
+
// 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),
|
|
930
|
+
// the same files of every other pnpm root the units belong to, and the linter configuration of their ancestor directories that the commit has.
|
|
931
|
+
const sharedNpmInputs = (members) => {
|
|
932
|
+
const required = new Set(SHARED_NPM_INPUTS); const optional = new Set();
|
|
933
|
+
for (const m of members) {
|
|
934
|
+
if ((m.root ?? ".") !== ".") for (const p of SHARED_NPM_INPUTS) required.add(inWs(m.root, p));
|
|
935
|
+
for (const dir of ancestorDirsOf(m.dir)) if (dir !== m.dir) for (const f of LINT_CONFIG_NAMES) optional.add(inWs(dir, f));
|
|
936
|
+
}
|
|
937
|
+
const nested = [...required].filter((p) => !SHARED_NPM_INPUTS.includes(p)).sort();
|
|
938
|
+
const extra = [...optional].filter((p) => !required.has(p)).sort();
|
|
939
|
+
return [...SHARED_NPM_INPUTS, ...nested].map((p) => [p, idOf(p)]).concat(extra.map((p) => [p, idOf(p)]).filter(([, id]) => id !== null && id !== undefined));
|
|
940
|
+
};
|
|
599
941
|
// A unit belongs to the Cargo workspace it names (rustUnits[].cargoDir, else the global one) and is a package of that workspace's
|
|
600
942
|
// own lock under its crate name (rustUnits[].crate, else its name); its closure never crosses into another workspace.
|
|
601
943
|
const wsOf = (u) => u.cargoDir ?? cargoDir;
|
|
@@ -650,11 +992,11 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
650
992
|
if (!unit) {
|
|
651
993
|
if (typeof readBlob !== "function") return whole();
|
|
652
994
|
// npm package scans: the package's workspace closure plus the root npm inputs.
|
|
653
|
-
const npmMatch = /^(test:npm|tsc|eslint):(.+)$/.exec(scan.id);
|
|
995
|
+
const npmMatch = /^(test:npm|tsc|eslint|biome):(.+)$/.exec(scan.id);
|
|
654
996
|
if (npmMatch && npmByUnit.has(npmMatch[2])) {
|
|
655
997
|
const { members, unresolved } = npmClosure(npmByUnit.get(npmMatch[2]));
|
|
656
998
|
if (unresolved) return whole();
|
|
657
|
-
return canonicalJson({ kind: "npm", id: scan.id, members: members.map((m) => [m.name, m.dir, idOf(m.dir)]), shared:
|
|
999
|
+
return canonicalJson({ kind: "npm", id: scan.id, members: members.map((m) => [m.name, m.dir, idOf(m.dir)]), shared: sharedNpmInputs(members) });
|
|
658
1000
|
}
|
|
659
1001
|
if (scan.id === "test:scripts-ci") return canonicalJson({ kind: "dirs", id: scan.id, inputs: SCRIPTS_CI_INPUTS.map((p) => [p, idOf(p)]) });
|
|
660
1002
|
return whole();
|
|
@@ -750,6 +1092,12 @@ export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-g
|
|
|
750
1092
|
|
|
751
1093
|
// ---- command resolution -------------------------------------------------------------------------------------
|
|
752
1094
|
|
|
1095
|
+
// The rustfmt this host can run: the one findRustfmt names, when it answers `--version` (a rustup proxy with no toolchain does not).
|
|
1096
|
+
function usableRustfmt(o) {
|
|
1097
|
+
const found = findRustfmt({ env: o.env, exists: o.exists, readdir: o.readdir, platform: o.platform, spawn: o.spawn });
|
|
1098
|
+
return found && !failed(o.spawn(found, ["--version"], { cwd: o.cwd })) ? found : null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
753
1101
|
// Cargo only through the broker: `rustright cargo`, or a `cargo` on PATH that is the managed shim (it runs cargo-guard.mjs).
|
|
754
1102
|
function resolveCargoCommand(o) {
|
|
755
1103
|
const which = (cmd) => {
|
|
@@ -780,7 +1128,12 @@ function releaseProvider(o, root, git) {
|
|
|
780
1128
|
return null;
|
|
781
1129
|
}
|
|
782
1130
|
|
|
783
|
-
|
|
1131
|
+
// The advisory ownership pre-commit step (`right-release hooks install`, ownership-hooks.mjs) runs the pinned @rightkit/release, so
|
|
1132
|
+
// it is written only where that is installed. Its own hook and the repository's .git/hooks/pre-commit both keep running.
|
|
1133
|
+
const PINNED_CLI = "node_modules/@rightkit/release/cli/right-release.mjs";
|
|
1134
|
+
const FOREIGN_PRE_COMMIT = "pre-commit.rightkit-foreign";
|
|
1135
|
+
|
|
1136
|
+
async function installHook(o, root, force) {
|
|
784
1137
|
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
785
1138
|
const hooksDir = path.join(root, ".githooks");
|
|
786
1139
|
const hookPath = path.join(hooksDir, "pre-push");
|
|
@@ -797,15 +1150,29 @@ function installHook(o, root, force) {
|
|
|
797
1150
|
const gitDirResult = git("rev-parse", "--git-dir");
|
|
798
1151
|
let present = [];
|
|
799
1152
|
if (!failed(gitDirResult) && text(gitDirResult).trim()) { try { present = o.readdir(path.join(path.resolve(root, text(gitDirResult).trim()), "hooks")); } catch { present = []; } }
|
|
1153
|
+
const gitHooksDir = !failed(gitDirResult) && text(gitDirResult).trim() ? path.join(path.resolve(root, text(gitDirResult).trim()), "hooks") : null;
|
|
800
1154
|
const own = present.filter(chainableHook).sort();
|
|
801
|
-
|
|
1155
|
+
// The ownership step takes .githooks/pre-commit. The repository's own pre-commit hook keeps running first, through a trampoline
|
|
1156
|
+
// beside it, unless it is the ownership hook a plain `right-release hooks install` wrote (this one replaces that), whose own
|
|
1157
|
+
// chained hook, pre-commit.rightkit-foreign, then runs instead. A name that is no plain hook name is reported, never chained.
|
|
1158
|
+
const ownership = o.exists(path.join(root, PINNED_CLI)) ? await import("../ownership-hooks.mjs") : null;
|
|
1159
|
+
const readHook = (name) => { try { return o.readFile(path.join(gitHooksDir, name)); } catch { return ""; } };
|
|
1160
|
+
const chainName = !ownership ? null : own.includes("pre-commit") && !readHook("pre-commit").includes(ownership.HOOK_MARKER) ? "pre-commit" : present.includes(FOREIGN_PRE_COMMIT) ? FOREIGN_PRE_COMMIT : null;
|
|
1161
|
+
const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name) && !(ownership && name === FOREIGN_PRE_COMMIT)).sort();
|
|
802
1162
|
if (skipped.length) o.err(`rightkit-gate: WARNING: not chaining .git/hooks/${skipped.join(", .git/hooks/")}: not a plain hook file name (letters, digits, - and _ only); run it from a hook of its own.`);
|
|
803
|
-
const trampolines = own.filter((name) => name !== "pre-push").map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name) }));
|
|
1163
|
+
const trampolines = own.filter((name) => name !== "pre-push" && !(ownership && name === "pre-commit")).map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name), note: `runs .git/hooks/${name}` }));
|
|
1164
|
+
if (ownership) {
|
|
1165
|
+
trampolines.push({ name: "pre-commit", file: path.join(hooksDir, "pre-commit"), wanted: ownership.renderPreCommitHook(), ownership: true, replaces: [trampolineText("pre-commit")], marker: ownership.HOOK_MARKER, note: "the advisory ownership gate: right-release ownership --staged" });
|
|
1166
|
+
if (chainName) trampolines.push({ name: FOREIGN_PRE_COMMIT, file: path.join(hooksDir, FOREIGN_PRE_COMMIT), wanted: trampolineText(chainName), note: `runs .git/hooks/${chainName}, before the ownership gate` });
|
|
1167
|
+
}
|
|
804
1168
|
for (const t of trampolines) {
|
|
805
1169
|
t.exists = o.exists(t.file);
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1170
|
+
const found = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") : null;
|
|
1171
|
+
t.current = found === t.wanted;
|
|
1172
|
+
// Our own earlier artifacts are updated in place: a plain trampoline where the ownership hook now goes, and an older ownership hook.
|
|
1173
|
+
const ours = found !== null && (t.current || t.replaces?.includes(found) || (t.marker && found.includes(t.marker)));
|
|
1174
|
+
if (t.exists && !ours && !force) {
|
|
1175
|
+
o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with ${t.ownership ? "the ownership pre-commit hook" : `a trampoline to .git/hooks/${chainName ?? t.name}`}.`);
|
|
809
1176
|
return 1;
|
|
810
1177
|
}
|
|
811
1178
|
}
|
|
@@ -825,7 +1192,7 @@ function installHook(o, root, force) {
|
|
|
825
1192
|
else put(hookPath, wanted, `${hasHook ? "replaced" : "wrote"} .githooks/pre-push`, ` (${nodePath} without pnpm)`);
|
|
826
1193
|
for (const t of trampolines) {
|
|
827
1194
|
if (t.current) done.push(`.githooks/${t.name} already current`);
|
|
828
|
-
else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (
|
|
1195
|
+
else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (${t.note})`);
|
|
829
1196
|
}
|
|
830
1197
|
if (existingPath === ".githooks") done.push("core.hooksPath already .githooks");
|
|
831
1198
|
else {
|
|
@@ -836,6 +1203,7 @@ function installHook(o, root, force) {
|
|
|
836
1203
|
for (const line of done) o.out(`rightkit-gate: ${line}`);
|
|
837
1204
|
const chained = own.length ? own.map((name) => (name === "pre-push" ? "pre-push (run first, then the gate)" : name)) : [];
|
|
838
1205
|
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"}`);
|
|
1206
|
+
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`);
|
|
839
1207
|
const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
|
|
840
1208
|
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\`.`);
|
|
841
1209
|
const provider = releaseProvider(o, root, git);
|
|
@@ -849,7 +1217,8 @@ function installHook(o, root, force) {
|
|
|
849
1217
|
/**
|
|
850
1218
|
* Run the CLI. Returns the exit code (0 pass, 1 blocked or failed, 2 cannot run, 128+n cancelled). `io` overrides the
|
|
851
1219
|
* process, fs and output bindings so tests execute nothing real: spawn, exists, readFile, writeFile, mkdir, chmod,
|
|
852
|
-
* readStdin, out, err, cwd, env, platform, abort (an AbortController)
|
|
1220
|
+
* readStdin, out, err, cwd, env, platform, abort (an AbortController), runScans and findRustfmt (the scan runner and the
|
|
1221
|
+
* lookup of a usable rustfmt, which the deferred Rust gate needs).
|
|
853
1222
|
*/
|
|
854
1223
|
export async function main(argv, io = {}) {
|
|
855
1224
|
const o = {
|
|
@@ -865,24 +1234,39 @@ export async function main(argv, io = {}) {
|
|
|
865
1234
|
}
|
|
866
1235
|
if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
|
|
867
1236
|
const sub = args[0] && !args[0].startsWith("-") ? args[0] : null;
|
|
868
|
-
if (sub && sub !== "install-hook") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
|
|
1237
|
+
if (sub && sub !== "install-hook" && sub !== "check") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
|
|
1238
|
+
// The app check has its own export and reads no gate state: it needs neither the base ref nor the pushed refs on stdin.
|
|
1239
|
+
if (sub === "check") return (o.appCheck ?? (await import("../app-check.mjs")).main)(args.slice(1), { out: o.out, err: o.err, label: "check", cwd: o.cwd });
|
|
869
1240
|
if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
|
|
870
1241
|
|
|
871
1242
|
const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: o.cwd });
|
|
872
1243
|
if (failed(top) || !text(top).trim()) { o.err("rightkit-gate: not inside a git repository"); return 2; }
|
|
873
1244
|
const root = path.resolve(text(top).trim());
|
|
874
1245
|
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
875
|
-
if (sub === "install-hook") return installHook(o, root, args.includes("--force"));
|
|
1246
|
+
if (sub === "install-hook") return await installHook(o, root, args.includes("--force"));
|
|
876
1247
|
|
|
877
1248
|
const planOnly = args.includes("--plan");
|
|
878
1249
|
let base = option("--base") ?? "origin/main";
|
|
879
1250
|
const tag = "rightkit-gate:";
|
|
880
1251
|
|
|
881
1252
|
// git pre-push: "<local ref> <local sha> <remote ref> <remote sha>" per pushed ref. A plan reads no stdin.
|
|
1253
|
+
// `pushedRefs` is every remote ref named, deletions included: it decides whether the Rust gate may defer (rustDeferral). The fields are
|
|
1254
|
+
// split on single ASCII spaces only (parsePushLines): a ref name may hold Unicode whitespace. A line that does not read as four fields
|
|
1255
|
+
// (git hands the local field over verbatim, so a rev expression such as `HEAD@{1 minute ago}` or `HEAD^{/one thing}` holds spaces)
|
|
1256
|
+
// refuses the push on every host and in every repository, never a line that disappears: its pushed commit could not be compared with
|
|
1257
|
+
// HEAD and its remote ref could not be judged. `pushedRemoteShas` are the remote commits being replaced.
|
|
1258
|
+
const pushedRefs = []; const pushedRemoteShas = [];
|
|
1259
|
+
const askedBase = base;
|
|
882
1260
|
if (!planOnly) {
|
|
883
1261
|
const head = text(git("rev-parse", "HEAD")).trim();
|
|
884
|
-
const
|
|
885
|
-
|
|
1262
|
+
const parsed = parsePushLines(o.readStdin());
|
|
1263
|
+
if (parsed.malformed.length) {
|
|
1264
|
+
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>)`);
|
|
1265
|
+
return 1;
|
|
1266
|
+
}
|
|
1267
|
+
for (const { localSha, remoteRef, remoteSha } of parsed.lines) {
|
|
1268
|
+
pushedRefs.push(remoteRef);
|
|
1269
|
+
if (!ZERO.test(remoteSha)) pushedRemoteShas.push(remoteSha);
|
|
886
1270
|
if (ZERO.test(localSha)) continue; // a delete pushes no content
|
|
887
1271
|
if (localSha !== head) {
|
|
888
1272
|
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`);
|
|
@@ -900,13 +1284,25 @@ export async function main(argv, io = {}) {
|
|
|
900
1284
|
const readLive = (f) => o.readFile(f);
|
|
901
1285
|
|
|
902
1286
|
// Units come from the live checkout (cargo reads it); a dirty checkout then blocks below.
|
|
903
|
-
let rust; let npm; let cargoCommand = ["rustright", "cargo"];
|
|
1287
|
+
let rust; let npm; let npmRoots = []; let cargoCommand = ["rustright", "cargo"]; let deferral = null; let manifests = []; const unplaced = [];
|
|
904
1288
|
try {
|
|
905
|
-
|
|
1289
|
+
manifests = tracked("Cargo.toml");
|
|
906
1290
|
if (manifests.length) {
|
|
907
1291
|
cargoCommand = resolveCargoCommand(o);
|
|
908
|
-
if (!cargoCommand) {
|
|
909
|
-
|
|
1292
|
+
if (!cargoCommand) {
|
|
1293
|
+
// No broker and no shim. A pre-push of non-protected branches outside CI defers the Rust gate to host qualification; anything
|
|
1294
|
+
// else refuses exactly as before. The cargo command is then only the text of the argv the deferred scans would have had.
|
|
1295
|
+
// The default branch is named by the checkout, the base, the remote commits being replaced and origin/HEAD, never by the pushed commit alone.
|
|
1296
|
+
const configured = defaultBranchNames({ git, readCheckout: () => o.readFile(path.join(root, ".rightgit.json")), revisions: [askedBase, ...pushedRemoteShas] });
|
|
1297
|
+
const decision = rustDeferral({ refs: pushedRefs, env: o.env, protectedBranches: configured });
|
|
1298
|
+
if (!decision.defer) {
|
|
1299
|
+
o.err(`${tag} BLOCKED: neither \`rustright\` nor the managed cargo shim (cargo-guard.mjs) is first on PATH; plain cargo is never used`);
|
|
1300
|
+
o.err(`${tag} not deferred to host qualification: ${decision.why}`);
|
|
1301
|
+
return 1;
|
|
1302
|
+
}
|
|
1303
|
+
deferral = decision;
|
|
1304
|
+
cargoCommand = ["rustright", "cargo"];
|
|
1305
|
+
} else 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; }
|
|
910
1306
|
}
|
|
911
1307
|
// Untracked (not ignored) manifests only: git does not descend into ignored directories, so this stays cheap. A member of a
|
|
912
1308
|
// workspace that is untracked or ignored is dropped by discoverRustWorkspaces itself.
|
|
@@ -918,16 +1314,23 @@ export async function main(argv, io = {}) {
|
|
|
918
1314
|
// consumer rows of the crates they exercise (CI treats them the same: scripts/ci/gate.mjs consumerRowInvocations), never as units.
|
|
919
1315
|
let fixtures = [];
|
|
920
1316
|
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 */ }
|
|
921
|
-
rust = discoverRustWorkspaces({
|
|
1317
|
+
rust = discoverRustWorkspaces({
|
|
1318
|
+
root, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, tracked, untracked, fixtures, readFile: readLive,
|
|
1319
|
+
...(deferral ? { metadata: (dir) => { const read = manifestWorkspaceMetadata({ root, dir, tracked, readFile: readLive }); unplaced.push(...(read.unplaced ?? [])); return read; } } : {}),
|
|
1320
|
+
});
|
|
922
1321
|
npm = discoverNpmUnits({ root, tracked, readFile: readLive, exists: o.exists });
|
|
1322
|
+
npmRoots = discoverPnpmRoots({ root, tracked, exists: o.exists });
|
|
923
1323
|
} catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
|
|
924
1324
|
for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
|
|
925
1325
|
|
|
926
1326
|
// Broker scans (cargo, pnpm) read the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
|
|
927
1327
|
// otherwise hide a committed failure. Any change or untracked file under those paths blocks the gate.
|
|
928
1328
|
if (!planOnly) {
|
|
1329
|
+
// The install graph and linter configuration of a pnpm root: the repository root's, and those of every other root (its units' directories
|
|
1330
|
+
// hold only the units, and a workspace member's lock sits above it).
|
|
1331
|
+
const npmRootFiles = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", ...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
|
|
929
1332
|
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",
|
|
930
|
-
"deny.toml", "clippy.toml", "rustfmt.toml",
|
|
1333
|
+
"deny.toml", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
|
|
931
1334
|
const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ...inputs);
|
|
932
1335
|
const dirtyFiles = failed(dirty) ? [`git status failed: ${why(dirty)}`]
|
|
933
1336
|
: text(dirty).split(/\r?\n/).filter(Boolean).filter((l) => !l.includes("node_modules") && !l.includes(GRANT_FILE));
|
|
@@ -981,11 +1384,27 @@ export async function main(argv, io = {}) {
|
|
|
981
1384
|
// The quiet tests (port, pipe, desktop and wall-clock bound) are proven by the CI shards, one crate per runner, unless
|
|
982
1385
|
// RK_GATE_QUIET_TESTS=local asks for the serial quiet lane here (no CI, or a CI outage).
|
|
983
1386
|
quietTests: o.env.RK_GATE_QUIET_TESTS === "local" ? "local" : "ci", packageFeatures,
|
|
984
|
-
rustUnits: rust.units, npmUnits: npm, cargoDir: ".", rustLocks, crateFeatures,
|
|
985
|
-
localCompile: o.exists(path.join(root, GRANT_FILE)),
|
|
1387
|
+
rustUnits: rust.units, npmUnits: npm, npmRoots, cargoDir: ".", rustLocks, crateFeatures,
|
|
1388
|
+
localCompile: deferral ? true : o.exists(path.join(root, GRANT_FILE)),
|
|
986
1389
|
exists: o.exists, readFile: o.readFile, platform: o.platform,
|
|
987
1390
|
});
|
|
988
|
-
|
|
1391
|
+
let plan = args.includes("--pre-push") ? prePushPlan(fullPlan) : fullPlan;
|
|
1392
|
+
// Deferred Rust gate: every scan that goes through the broker is taken out of the plan and named; rustfmt too when this host has
|
|
1393
|
+
// none (it is a Rust-toolchain scan, not a content scan, on such a host). Every other scan stays exactly as planned.
|
|
1394
|
+
let notRun = []; let deferNote = null; let npmCalls = [];
|
|
1395
|
+
if (deferral) {
|
|
1396
|
+
const rustfmt = (o.findRustfmt ?? usableRustfmt)(o);
|
|
1397
|
+
const dropped = plan.scans.filter((s) => s.tool === "rustright" || (s.tool === "rustfmt" && !rustfmt));
|
|
1398
|
+
// An npm test scan whose script runs `rustright` or `cargo` through its package script chain (planScans marks it `brokerCall`: ViewRight's
|
|
1399
|
+
// test:e2e is `rustright storage run ... -- right-release test`) cannot run on this host either. It goes the way of the Rust scans, named
|
|
1400
|
+
// with its reason and never passed. On a host with the broker, in CI, on a protected ref or with the opt-out there is no deferral and it runs.
|
|
1401
|
+
const npmDropped = plan.scans.filter((s) => s.brokerCall && s.tool === "pnpm" && !dropped.includes(s));
|
|
1402
|
+
npmCalls = npmDropped.map((s) => ({ id: s.id, ...s.brokerCall }));
|
|
1403
|
+
notRun = [...dropped, ...npmDropped].map((s) => s.id);
|
|
1404
|
+
plan = withoutScans(plan, [...dropped, ...npmDropped]);
|
|
1405
|
+
deferNote = `no RustRight broker on this host: ${dropped.length} Rust scan(s) are deferred to host qualification, not run and not passed${rustfmt ? "" : " (rustfmt is not usable on this host either)"}`
|
|
1406
|
+
+ (npmDropped.length ? `; ${npmDropped.length} npm test scan(s) whose script runs the broker or cargo are deferred with them, not run and not passed: ${npmDropped.map((s) => s.id).join(", ")}` : "");
|
|
1407
|
+
}
|
|
989
1408
|
const jobs = Math.max(1, Number(o.env.RK_GATE_JOBS ?? 4) || 4);
|
|
990
1409
|
const testJobs = Math.max(1, Number(o.env.RK_GATE_TEST_JOBS ?? jobs) || jobs);
|
|
991
1410
|
|
|
@@ -1000,6 +1419,7 @@ export async function main(argv, io = {}) {
|
|
|
1000
1419
|
}
|
|
1001
1420
|
|
|
1002
1421
|
for (const n of plan.notes) o.out(`${tag} note: ${n}`);
|
|
1422
|
+
if (deferNote) o.out(`${tag} note: ${deferNote}`);
|
|
1003
1423
|
const quietCount = plan.scans.filter((s) => s.quiet === true).length;
|
|
1004
1424
|
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` : ""}`);
|
|
1005
1425
|
let signalCode = 0;
|
|
@@ -1041,7 +1461,7 @@ export async function main(argv, io = {}) {
|
|
|
1041
1461
|
}
|
|
1042
1462
|
} catch { cache = null; }
|
|
1043
1463
|
}
|
|
1044
|
-
const scanResult = await runScans(plan, {
|
|
1464
|
+
const scanResult = await (o.runScans ?? runScans)(plan, {
|
|
1045
1465
|
classes: ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
|
|
1046
1466
|
quietLane: quietLeaseLane(o, { tag, reason: `rightkit-gate-quiet-tests@${text(git("rev-parse", "--short", "HEAD")).trim()}` }),
|
|
1047
1467
|
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)}` : ""}`),
|
|
@@ -1063,6 +1483,12 @@ export async function main(argv, io = {}) {
|
|
|
1063
1483
|
o.err("Fix and push again. Never bypass with --no-verify.");
|
|
1064
1484
|
return 1;
|
|
1065
1485
|
}
|
|
1486
|
+
if (deferral) {
|
|
1487
|
+
o.out(`${tag} the scans that could run here passed (${changed.length} changed file(s) vs ${base}${scanResult.cacheHits.length ? `; ${scanResult.cacheHits.length} reused from the result cache` : ""})`);
|
|
1488
|
+
const unmatched = [...new Set([...unplaced, ...unmatchedManifests({ root, manifests, rust, readFile: readLive })])].sort();
|
|
1489
|
+
for (const line of deferredBlock({ units: rust.units.map((u) => u.name), notRun, refs: pushedRefs, manifests: manifests.length, unmatched, npmCalls, tag })) o.err(line);
|
|
1490
|
+
return 0;
|
|
1491
|
+
}
|
|
1066
1492
|
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` : ""}`);
|
|
1067
1493
|
return 0;
|
|
1068
1494
|
} finally {
|