@rightkit/release 0.2.174 → 0.3.0
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 +178 -1
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/cli/right-release.mjs +29 -3
- package/command-spec.mjs +80 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/icon-pack.mjs +117 -0
- package/init-command.mjs +116 -0
- package/install-app.mjs +36 -3
- package/local-publish.mjs +317 -29
- package/macho-approval-hash.mjs +181 -0
- package/managed-cargo.mjs +49 -1
- package/media-sidecar.mjs +20 -4
- package/mirror-root-artifact.mjs +69 -21
- package/npm-license-notices.mjs +159 -0
- package/ownership-dependency-allowlist.json +1 -0
- package/ownership.mjs +37 -4
- package/package-recipe-targets.mjs +15 -1
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/publication-gate.mjs +70 -3
- package/publish-cargo.mjs +26 -7
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-switch-scan.mjs +17 -7
- package/release.mjs +75 -4
- package/rightkit-ownership.json +46 -2
- package/rightkit-versions.json +5 -4
- package/runtime-budgets.mjs +14 -7
- package/stage-sidecar.mjs +505 -68
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/web-build.mjs +67 -0
package/local-publish.mjs
CHANGED
|
@@ -5,18 +5,25 @@
|
|
|
5
5
|
// scripts/ci/gate.mjs semantics), packs the artifact, writes a schema-1 evidence file with one darwin receipt
|
|
6
6
|
// (hostPolicy "darwin-only"), then calls the EXISTING enforcePublicationGate and the EXISTING publisher
|
|
7
7
|
// (publishGatedTarball for npm, runCargoPublish for cargo). There is no way to skip a test or the evidence.
|
|
8
|
+
//
|
|
9
|
+
// Batch mode (`cargo --crates a,b,c | --crates-file f --local`): fmt, the secret scan and clippy run ONCE for
|
|
10
|
+
// the batch at one commit and tree and are recorded in every crate's evidence as a `batch` receipt that the
|
|
11
|
+
// publication gate validates (publication-gate.mjs batchReceiptProblems); each crate still runs its own tests,
|
|
12
|
+
// all-features check, packaging, gate, publish and registry readback, and writes its own evidence file. Crates
|
|
13
|
+
// of one dependency layer publish concurrently (bounded); layer N+1 starts only after every layer-N crate is
|
|
14
|
+
// visible in the registry index with the verified checksum.
|
|
8
15
|
import crypto from "node:crypto";
|
|
9
16
|
import fs from "node:fs";
|
|
10
17
|
import os from "node:os";
|
|
11
18
|
import path from "node:path";
|
|
12
19
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
20
|
import { spawnSync } from "./background-process.mjs";
|
|
14
|
-
import { runCargoSync } from "./managed-cargo.mjs";
|
|
21
|
+
import { runCargoAsync, runCargoSync, spawnCollect } from "./managed-cargo.mjs";
|
|
15
22
|
import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
|
|
16
23
|
import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
|
|
17
|
-
import { runCargoPublish } from "./publish-cargo.mjs";
|
|
24
|
+
import { runCargoPublish, scanCrateForSecrets } from "./publish-cargo.mjs";
|
|
18
25
|
import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
|
|
19
|
-
import { readCargoPackage } from "./crate-graph.mjs";
|
|
26
|
+
import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
|
|
20
27
|
import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
|
|
21
28
|
import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
|
|
22
29
|
|
|
@@ -29,9 +36,74 @@ export class LocalPublishError extends Error {
|
|
|
29
36
|
}
|
|
30
37
|
const refuse = (message) => { throw new LocalPublishError(message); };
|
|
31
38
|
|
|
39
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
40
|
+
const MAX_CONCURRENCY = 8;
|
|
41
|
+
const CRATE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
42
|
+
|
|
43
|
+
const crateList = (text, what) => {
|
|
44
|
+
const names = String(text ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
45
|
+
for (const name of names) if (!CRATE_NAME.test(name)) refuse(`${what}: invalid crate name ${JSON.stringify(name)}`);
|
|
46
|
+
return names;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A crates file: one crate per line, blank lines separate dependency layers (earlier layers publish first),
|
|
51
|
+
* `#` starts a comment. Returns the layers; refuses a bad name, a repeated crate or an empty file.
|
|
52
|
+
*/
|
|
53
|
+
export function parseCrateLayers(text) {
|
|
54
|
+
const layers = []; let current = [];
|
|
55
|
+
const flush = () => { if (current.length) layers.push(current); current = []; };
|
|
56
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
57
|
+
if (raw.trim().startsWith("#")) continue;
|
|
58
|
+
const line = raw.replace(/\s+#.*$/, "").trim();
|
|
59
|
+
if (!line) { flush(); continue; }
|
|
60
|
+
if (!CRATE_NAME.test(line)) refuse(`crates file: invalid crate name ${JSON.stringify(line)}`);
|
|
61
|
+
current.push(line);
|
|
62
|
+
}
|
|
63
|
+
flush();
|
|
64
|
+
const all = layers.flat();
|
|
65
|
+
const repeated = all.find((name, index) => all.indexOf(name) !== index);
|
|
66
|
+
if (repeated) refuse(`crates file names ${repeated} more than once`);
|
|
67
|
+
if (!all.length) refuse("crates file names no crate");
|
|
68
|
+
return layers;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** An explicit layering must put every in-batch dependency of a crate in a STRICTLY earlier layer. */
|
|
72
|
+
export function checkCrateLayers(layers, dependsOn) {
|
|
73
|
+
const layerOf = new Map();
|
|
74
|
+
layers.forEach((layer, index) => layer.forEach((name) => layerOf.set(name, index)));
|
|
75
|
+
for (const [name, index] of layerOf) {
|
|
76
|
+
for (const dependency of dependsOn(name)) {
|
|
77
|
+
if (!layerOf.has(dependency)) continue;
|
|
78
|
+
if (layerOf.get(dependency) >= index) refuse(`${name} (layer ${index + 1}) depends on ${dependency} (layer ${layerOf.get(dependency) + 1}); a dependency must be in an earlier layer`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return layers;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Layers derived from the in-batch dependency edges: layer = 1 + the deepest in-batch dependency. */
|
|
85
|
+
export function deriveCrateLayers(crates, dependsOn) {
|
|
86
|
+
const member = new Set(crates);
|
|
87
|
+
const level = new Map(); const visiting = new Set();
|
|
88
|
+
const visit = (name) => {
|
|
89
|
+
if (level.has(name)) return level.get(name);
|
|
90
|
+
if (visiting.has(name)) refuse(`dependency cycle among the batch crates at ${name}`);
|
|
91
|
+
visiting.add(name);
|
|
92
|
+
const depth = Math.max(-1, ...dependsOn(name).filter((dependency) => member.has(dependency) && dependency !== name).map(visit)) + 1;
|
|
93
|
+
visiting.delete(name); level.set(name, depth);
|
|
94
|
+
return depth;
|
|
95
|
+
};
|
|
96
|
+
crates.forEach(visit);
|
|
97
|
+
const layers = [];
|
|
98
|
+
for (const name of crates) (layers[level.get(name)] ??= []).push(name);
|
|
99
|
+
return layers;
|
|
100
|
+
}
|
|
101
|
+
|
|
32
102
|
export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
|
|
33
103
|
const flag = kind === "npm" ? "--package" : "--crate";
|
|
104
|
+
const batchFlags = kind === "cargo" ? ", --crates <a,b,c>, --crates-file <file>, --concurrency <n>, --allow-ignored-units <a,b>" : "";
|
|
34
105
|
let unit; let workspace; let local = false; let allowIgnored = false;
|
|
106
|
+
let crates; let cratesFile; let concurrency = DEFAULT_CONCURRENCY; let allowIgnoredUnits = [];
|
|
35
107
|
for (let i = 0; i < argv.length; i += 1) {
|
|
36
108
|
const arg = argv[i];
|
|
37
109
|
if (arg === "--local") local = true;
|
|
@@ -39,12 +111,27 @@ export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
|
|
|
39
111
|
else if (arg === "--workspace") workspace = argv[++i];
|
|
40
112
|
// Same meaning as publish.yml's allow_ignored: this unit may have ignored tests; the count is recorded.
|
|
41
113
|
else if (arg === "--allow-ignored") allowIgnored = true;
|
|
42
|
-
else
|
|
114
|
+
else if (kind === "cargo" && arg === "--crates") crates = crateList(argv[++i], "--crates");
|
|
115
|
+
else if (kind === "cargo" && arg === "--crates-file") cratesFile = argv[++i];
|
|
116
|
+
else if (kind === "cargo" && arg === "--concurrency") concurrency = Number(argv[++i]);
|
|
117
|
+
else if (kind === "cargo" && arg === "--allow-ignored-units") allowIgnoredUnits = crateList(argv[++i], "--allow-ignored-units");
|
|
118
|
+
else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>${batchFlags}, --workspace <dir>, --allow-ignored, --local)`);
|
|
43
119
|
}
|
|
44
120
|
if (!local) refuse("--local is required");
|
|
121
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) refuse(`--concurrency must be an integer from 1 to ${MAX_CONCURRENCY}`);
|
|
122
|
+
const workspaceRoot = path.resolve(workspace ?? cwd);
|
|
123
|
+
if (kind === "cargo" && (crates !== undefined || cratesFile !== undefined)) {
|
|
124
|
+
if (unit !== undefined) refuse("--crate cannot be combined with --crates or --crates-file");
|
|
125
|
+
if (crates !== undefined && cratesFile !== undefined) refuse("use --crates or --crates-file, not both");
|
|
126
|
+
if (crates !== undefined && !crates.length) refuse("--crates names no crate");
|
|
127
|
+
if (cratesFile !== undefined && !String(cratesFile ?? "").trim()) refuse("--crates-file needs a file");
|
|
128
|
+
const repeated = crates?.find((name, index) => crates.indexOf(name) !== index);
|
|
129
|
+
if (repeated) refuse(`--crates names ${repeated} more than once`);
|
|
130
|
+
return { kind, unit: undefined, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: { crates, file: cratesFile === undefined ? undefined : path.resolve(cwd, cratesFile) } };
|
|
131
|
+
}
|
|
45
132
|
if (kind === "npm" && !/^@rightkit\/[a-z0-9-]+$/.test(unit ?? "")) refuse("publish npm --local requires --package @rightkit/<name>");
|
|
46
|
-
if (kind === "cargo" &&
|
|
47
|
-
return { kind, unit, allowIgnored, workspaceRoot
|
|
133
|
+
if (kind === "cargo" && !CRATE_NAME.test(unit ?? "")) refuse("publish cargo --local requires --crate <name> (or --crates / --crates-file)");
|
|
134
|
+
return { kind, unit, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: undefined };
|
|
48
135
|
}
|
|
49
136
|
|
|
50
137
|
function defaultGit(args, cwd) {
|
|
@@ -74,6 +161,40 @@ function defaultRun(command, args, options) {
|
|
|
74
161
|
return command === "cargo" ? runCargoSync(args, settings) : spawnSync(command, args, settings);
|
|
75
162
|
}
|
|
76
163
|
|
|
164
|
+
// Async twin used by batch mode so crates of one layer can overlap their uploads and index waits. The broker
|
|
165
|
+
// still runs builds one at a time; `await` on a synchronous fake works the same.
|
|
166
|
+
function defaultRunAsync(command, args, options) {
|
|
167
|
+
const settings = { encoding: "utf8", maxBuffer: 512 * 1024 * 1024, timeout: STEP_TIMEOUT_MS, windowsHide: true, ...options };
|
|
168
|
+
return command === "cargo" ? runCargoAsync(args, settings) : spawnCollect(command, args, settings);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `execute` for runCargoPublish in batch mode: the async Cargo runner, with the output of a step that would
|
|
173
|
+
* inherit stdio echoed line by line under the crate name (concurrent crates would otherwise interleave raw).
|
|
174
|
+
*/
|
|
175
|
+
export function batchCargoExecute(label, { run = runCargoAsync, out = process.stdout, err = process.stderr } = {}) {
|
|
176
|
+
return async (_command, args, settings) => {
|
|
177
|
+
const { stdio, ...rest } = settings;
|
|
178
|
+
const result = await run(args, { ...rest, timeout: STEP_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
|
|
179
|
+
if (stdio === "inherit") {
|
|
180
|
+
for (const [stream, text] of [[out, result.stdout], [err, result.stderr]]) {
|
|
181
|
+
for (const line of String(text ?? "").split(/\r?\n/)) if (line) stream.write(`[${label}] ${line}\n`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Registry versions-file writers (recordPublished, stageSourceVersions) read-modify-write one shared JSON file
|
|
189
|
+
// and its consumer copies. They are synchronous, but the lock keeps the whole write one unit even if a writer
|
|
190
|
+
// becomes async, so concurrent crates of a layer can never interleave two updates.
|
|
191
|
+
let versionsTail = Promise.resolve();
|
|
192
|
+
const withVersionsLock = (task) => {
|
|
193
|
+
const result = versionsTail.then(task);
|
|
194
|
+
versionsTail = result.catch(() => {});
|
|
195
|
+
return result;
|
|
196
|
+
};
|
|
197
|
+
|
|
77
198
|
async function defaultLoadWorkspaceModules(workspaceRoot) {
|
|
78
199
|
const load = (file) => import(pathToFileURL(path.join(workspaceRoot, "scripts", "ci", file)).href);
|
|
79
200
|
const [results, upload] = await Promise.all([load("test-results.mjs"), load("upload.mjs")]);
|
|
@@ -92,7 +213,7 @@ export function allFeatureCheckSets(entry = {}) {
|
|
|
92
213
|
}
|
|
93
214
|
|
|
94
215
|
/** The CI gate's commands for one unit (scripts/ci/gate.mjs npmGate/rustGate), mirrored exactly. */
|
|
95
|
-
export function localTestPlan({ kind, unit, dir, config, manifest }) {
|
|
216
|
+
export function localTestPlan({ kind, unit, dir, config, manifest, batch = false }) {
|
|
96
217
|
if (kind === "npm") {
|
|
97
218
|
if (!manifest?.scripts?.test) refuse(`${unit} has no test script; --local never publishes an untested package`);
|
|
98
219
|
return {
|
|
@@ -111,26 +232,75 @@ export function localTestPlan({ kind, unit, dir, config, manifest }) {
|
|
|
111
232
|
steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--show-output"], test: true });
|
|
112
233
|
if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
|
|
113
234
|
}
|
|
114
|
-
|
|
235
|
+
// Batch mode lints the whole batch once (batchClippyInvocations) and records it as a batch receipt.
|
|
236
|
+
if (!batch) steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--no-deps", "--all-targets", ...featureSets[0], "--", "-D", "warnings"], test: false });
|
|
115
237
|
for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--all-targets", ...features], test: false });
|
|
116
238
|
return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
|
|
117
239
|
}
|
|
118
240
|
|
|
241
|
+
/**
|
|
242
|
+
* The clippy invocations that replace each crate's own `cargo clippy -p <crate> --no-deps --all-targets
|
|
243
|
+
* <featureSets[0]> -- -D warnings`: same flags and lint level, so the same lints on the same code. Crates
|
|
244
|
+
* whose first feature set is empty or `--all-features` share one `-p a -p b` invocation (the flags mean the
|
|
245
|
+
* same for every crate); any other feature set runs per crate, because `--features x` must not reach a crate
|
|
246
|
+
* that lacks x. The caller passes ONE dependency layer: no selected crate depends on another, so `-p` does not
|
|
247
|
+
* unify extra features into a selected crate and each is linted with exactly the features it was before.
|
|
248
|
+
* Crates with a different RIGHTKIT_REQUIRE_ASSETS (asset-gated) stay apart so the environment is unchanged.
|
|
249
|
+
*/
|
|
250
|
+
export function batchClippyInvocations(layer, { featuresOf = () => [], assetGatedOf = () => false } = {}) {
|
|
251
|
+
const groups = new Map();
|
|
252
|
+
for (const unit of layer) {
|
|
253
|
+
const features = featuresOf(unit) ?? [];
|
|
254
|
+
const shared = features.length === 0 || (features.length === 1 && features[0] === "--all-features");
|
|
255
|
+
const key = shared ? JSON.stringify([features, assetGatedOf(unit)]) : `unit:${unit}`;
|
|
256
|
+
if (!groups.has(key)) groups.set(key, { crates: [], features, assetGated: assetGatedOf(unit) });
|
|
257
|
+
groups.get(key).crates.push(unit);
|
|
258
|
+
}
|
|
259
|
+
return [...groups.values()].map(({ crates, features, assetGated }) => ({
|
|
260
|
+
crates, assetGated,
|
|
261
|
+
args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
|
|
119
265
|
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
|
120
266
|
const tarballName = (name, version) => `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`;
|
|
121
267
|
const tail = (text, lines = 40) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
|
|
122
268
|
|
|
269
|
+
// Tests run under hosted-CI conditions: no inherited credentials (a managed host refuses a token from the
|
|
270
|
+
// environment) and no sibling product checkouts (CI holds rightkit alone).
|
|
271
|
+
function testEnv(deps, isAssetGated) {
|
|
272
|
+
const inherited = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([key]) => !/(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)/i.test(key)));
|
|
273
|
+
return { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function assertLocalHost(deps, workspaceRoot) {
|
|
277
|
+
const platform = deps.platform ?? process.platform;
|
|
278
|
+
if (!(deps.localBuildGranted ?? localBuildGranted)(workspaceRoot)) refuse(`--local needs the local-build grant: a committed ${LOCAL_BUILD_FILE} (allow "build-test-publish") naming this repository at ${workspaceRoot}`);
|
|
279
|
+
if (platform !== "darwin") refuse(`--local publishes from the Mac only (host policy: darwin-only); this host is ${platform}`);
|
|
280
|
+
return platform;
|
|
281
|
+
}
|
|
282
|
+
|
|
123
283
|
export async function runLocalPublish(kind, argv, deps = {}) {
|
|
284
|
+
const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
|
|
285
|
+
if (parsed.batch) return runLocalPublishBatch(parsed, deps);
|
|
286
|
+
return publishUnit(parsed, deps, null);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Qualify and publish ONE unit. `batch` (cargo batch mode only) is `{ head, tree, receipt, commands }`: the
|
|
291
|
+
* batch-level fmt, secret scan and clippy already ran at `head`/`tree`, so this unit skips its own clippy and
|
|
292
|
+
* copies the receipt into its evidence; everything else (tests, all-features check, packaging, the gate, the
|
|
293
|
+
* publish and the registry readback) runs for this unit exactly as in single mode.
|
|
294
|
+
*/
|
|
295
|
+
async function publishUnit(parsed, deps, batch) {
|
|
124
296
|
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
125
|
-
const { unit, allowIgnored, workspaceRoot } =
|
|
126
|
-
const platform = deps.platform ?? process.platform;
|
|
297
|
+
const { kind, unit, allowIgnored, workspaceRoot } = parsed;
|
|
127
298
|
const git = deps.git ?? defaultGit;
|
|
128
299
|
const readJson = deps.readJson ?? defaultReadJson;
|
|
129
|
-
const run = deps.run ?? defaultRun;
|
|
300
|
+
const run = deps.run ?? (batch ? defaultRunAsync : defaultRun);
|
|
130
301
|
|
|
131
302
|
// 1. Preconditions, fail closed.
|
|
132
|
-
|
|
133
|
-
if (platform !== "darwin") refuse(`--local publishes from the Mac only (host policy: darwin-only); this host is ${platform}`);
|
|
303
|
+
const platform = assertLocalHost(deps, workspaceRoot);
|
|
134
304
|
const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
|
|
135
305
|
const unitPath = path.join(workspaceRoot, dir);
|
|
136
306
|
if (!fs.existsSync(unitPath)) refuse(`unit directory not found: ${unitPath}`);
|
|
@@ -148,6 +318,12 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
148
318
|
if (typeof version !== "string" || !version.trim()) refuse(`${unit} has no literal version`);
|
|
149
319
|
const head = git(["rev-parse", "HEAD"], workspaceRoot);
|
|
150
320
|
if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
|
|
321
|
+
if (batch) {
|
|
322
|
+
// The batch receipt is valid for one commit and tree only; a moved HEAD would make it describe other code.
|
|
323
|
+
if (head !== batch.head) refuse(`${unit}: HEAD moved to ${head} during the batch (batch commit ${batch.head})`);
|
|
324
|
+
const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
|
|
325
|
+
if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
|
|
326
|
+
}
|
|
151
327
|
const dirty = git(["status", "--porcelain", "--", dir], workspaceRoot);
|
|
152
328
|
if (dirty === null) refuse(`cannot read git status for ${dir}`);
|
|
153
329
|
if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
|
|
@@ -159,18 +335,19 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
159
335
|
const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
|
|
160
336
|
const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
|
|
161
337
|
const isAssetGated = Object.hasOwn(gatedSection, unit);
|
|
162
|
-
const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest });
|
|
338
|
+
const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch) });
|
|
163
339
|
const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
|
|
164
340
|
const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
const
|
|
341
|
+
const env = testEnv(deps, isAssetGated);
|
|
342
|
+
// Ignored tests are refused unless the unit is named: --allow-ignored / --allow-ignored-units, or a
|
|
343
|
+
// written reason in ci/gate.config.json (rust.<crate>.allowIgnored). The count stays in the evidence.
|
|
344
|
+
const configReason = kind === "cargo" && typeof gateConfig?.rust?.[unit]?.allowIgnored === "string" ? gateConfig.rust[unit].allowIgnored.trim() : "";
|
|
345
|
+
const ignoredAllowed = Boolean(allowIgnored || parsed.allowIgnoredUnits?.includes(unit) || configReason);
|
|
169
346
|
const steps = [];
|
|
170
347
|
for (const step of plan.steps) {
|
|
171
348
|
const commandText = `${step.command} ${step.args.join(" ")}`;
|
|
172
349
|
log(`[right-release] ${plan.id}: ${commandText}`);
|
|
173
|
-
const result = run(step.command, step.args, { cwd: workspaceRoot, env });
|
|
350
|
+
const result = await run(step.command, step.args, { cwd: workspaceRoot, env });
|
|
174
351
|
const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
|
|
175
352
|
if (result.error || result.status !== 0) {
|
|
176
353
|
steps.push(row);
|
|
@@ -185,7 +362,7 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
185
362
|
}
|
|
186
363
|
steps.push(row);
|
|
187
364
|
}
|
|
188
|
-
const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored:
|
|
365
|
+
const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored: ignoredAllowed ? [unit] : [], allowMissingAssets: isAssetGated ? [unit] : [] });
|
|
189
366
|
if (verdict.status === "fail" || steps.some((s) => s.failed > 0)) refuse(`${plan.id} not qualified: ${verdict.problems.join("; ") || "failed tests reported"}`);
|
|
190
367
|
if (!(verdict.executed > 0)) refuse(`${plan.id} executed no test`);
|
|
191
368
|
log(`[right-release] ${plan.id}: ${verdict.executed} passed, 0 failed, ${verdict.skipped} skipped, ${verdict.ignored} ignored${isAssetGated ? " (asset-gated: RIGHTKIT_REQUIRE_ASSETS=0, skips recorded)" : ""}`);
|
|
@@ -204,21 +381,23 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
204
381
|
if (kind === "npm") {
|
|
205
382
|
// The feed a tarball ships must list the version it ships with (#319): stage it before packing.
|
|
206
383
|
const feedFile = path.join(workspaceRoot, "packages", "release", "rightkit-versions.json");
|
|
207
|
-
if (fs.existsSync(feedFile)) (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log });
|
|
384
|
+
if (fs.existsSync(feedFile)) await withVersionsLock(() => (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log }));
|
|
208
385
|
const args = ["--dir", dir, "pack", "--pack-destination", scratch];
|
|
209
386
|
packCommands.push(`pnpm ${args.join(" ")}`);
|
|
210
|
-
const packed = run("pnpm", args, { cwd: workspaceRoot, env });
|
|
387
|
+
const packed = await run("pnpm", args, { cwd: workspaceRoot, env });
|
|
211
388
|
if (packed.error || packed.status !== 0) refuse(`pnpm pack failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
|
|
212
389
|
artifactFile = path.join(scratch, tarballName(unit, version));
|
|
213
390
|
if (!fs.existsSync(artifactFile)) refuse(`pnpm pack did not produce ${path.basename(artifactFile)}`);
|
|
214
391
|
const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
|
|
215
392
|
if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
|
|
216
393
|
} else {
|
|
217
|
-
|
|
218
|
-
|
|
394
|
+
// The archive is hashed here; Cargo's verify build runs once, in the real `cargo publish` (before any
|
|
395
|
+
// upload), instead of here and again in a registry dry-run (see runCargoPublish verifyOnPublish).
|
|
396
|
+
packCommands.push(`cargo package --locked --no-verify -p ${unit}`);
|
|
397
|
+
const packed = await run("cargo", ["package", "--locked", "--no-verify", "-p", unit], { cwd: workspaceRoot, env });
|
|
219
398
|
if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
|
|
220
399
|
packCommands.push("cargo metadata --format-version 1 --no-deps");
|
|
221
|
-
const meta = run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
|
|
400
|
+
const meta = await run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
|
|
222
401
|
let targetDir;
|
|
223
402
|
try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
|
|
224
403
|
if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) refuse("cannot read the Cargo target directory");
|
|
@@ -228,7 +407,7 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
228
407
|
const artifactSha256 = sha256(fs.readFileSync(artifactFile));
|
|
229
408
|
|
|
230
409
|
// 4. Evidence in the normal schema, then the EXISTING gate.
|
|
231
|
-
const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands];
|
|
410
|
+
const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands, ...(batch ? batch.commands : [])];
|
|
232
411
|
const timestamp = (deps.now ?? (() => new Date().toISOString()))();
|
|
233
412
|
const evidence = {
|
|
234
413
|
schema: GATE_SCHEMA, kind, name: unit, version, commit: head, artifactSha256,
|
|
@@ -236,8 +415,10 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
236
415
|
runs: [{
|
|
237
416
|
host: { os: "darwin", arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
|
|
238
417
|
command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
|
|
239
|
-
passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored,
|
|
418
|
+
passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored: ignoredAllowed,
|
|
419
|
+
...(configReason ? { allowIgnoredReason: configReason } : {}),
|
|
240
420
|
requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
|
|
421
|
+
...(batch ? { batch: structuredClone(batch.receipt) } : {}),
|
|
241
422
|
steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
|
|
242
423
|
}],
|
|
243
424
|
};
|
|
@@ -252,8 +433,12 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
252
433
|
if (kind === "npm") {
|
|
253
434
|
(deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
|
|
254
435
|
} else {
|
|
255
|
-
|
|
256
|
-
|
|
436
|
+
// trustEvidenceSteps: the evidence just written (and validated by the gate above) proves this commit's tests
|
|
437
|
+
// and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
|
|
438
|
+
// publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
|
|
439
|
+
const execute = deps.cargoExecute ?? (batch ? batchCargoExecute(unit) : undefined);
|
|
440
|
+
const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath, trustEvidenceSteps: true, verifyOnPublish: true }, execute, deps.cargoDeps ?? {});
|
|
441
|
+
await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }));
|
|
257
442
|
}
|
|
258
443
|
log(`[right-release] published ${unit}@${version}`);
|
|
259
444
|
return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
|
|
@@ -262,6 +447,109 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
262
447
|
}
|
|
263
448
|
}
|
|
264
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Batch publish (cargo only): validate the whole batch and run fmt, the secret scan and clippy once at one
|
|
452
|
+
* commit and tree, failing the batch closed before anything is uploaded; then publish layer by layer, the
|
|
453
|
+
* crates of a layer concurrently (bounded by --concurrency). A failure stops new work: no crate of a later
|
|
454
|
+
* layer starts, crates of the failing layer that were already running finish, and the refusal lists what
|
|
455
|
+
* published. Re-run with the published crates removed from the list.
|
|
456
|
+
*/
|
|
457
|
+
async function runLocalPublishBatch(parsed, deps) {
|
|
458
|
+
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
459
|
+
const { workspaceRoot, concurrency } = parsed;
|
|
460
|
+
const git = deps.git ?? defaultGit;
|
|
461
|
+
const readJson = deps.readJson ?? defaultReadJson;
|
|
462
|
+
const run = deps.run ?? defaultRunAsync;
|
|
463
|
+
assertLocalHost(deps, workspaceRoot);
|
|
464
|
+
|
|
465
|
+
// 1. The crate list and its dependency layers.
|
|
466
|
+
const requested = parsed.batch.file ? parseCrateLayers((deps.readText ?? ((file) => fs.readFileSync(file, "utf8")))(parsed.batch.file)) : [parsed.batch.crates];
|
|
467
|
+
const crates = requested.flat();
|
|
468
|
+
const dependenciesOf = deps.batchDependencies ?? ((unit) => {
|
|
469
|
+
const members = new Set(crates);
|
|
470
|
+
return pathDependencyDirs(path.join(workspaceRoot, "crates", unit)).map((dir) => path.basename(dir)).filter((name) => members.has(name) && name !== unit);
|
|
471
|
+
});
|
|
472
|
+
const layers = parsed.batch.file ? checkCrateLayers(requested, dependenciesOf) : deriveCrateLayers(crates, dependenciesOf);
|
|
473
|
+
|
|
474
|
+
// 2. Preconditions for every crate before anything runs, fail closed.
|
|
475
|
+
const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
|
|
476
|
+
const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
|
|
477
|
+
const isAssetGated = (unit) => Object.hasOwn(assetGated.crates ?? {}, unit);
|
|
478
|
+
const versions = new Map();
|
|
479
|
+
for (const unit of crates) {
|
|
480
|
+
const dir = `crates/${unit}`;
|
|
481
|
+
if (!fs.existsSync(path.join(workspaceRoot, dir))) refuse(`unit directory not found: ${path.join(workspaceRoot, dir)}`);
|
|
482
|
+
const pkg = (deps.readCargoPackage ?? readCargoPackage)(path.join(workspaceRoot, dir));
|
|
483
|
+
if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
|
|
484
|
+
if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
|
|
485
|
+
versions.set(unit, pkg.version);
|
|
486
|
+
localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true }); // refuses an OS-restricted crate now, not mid-batch
|
|
487
|
+
}
|
|
488
|
+
const head = git(["rev-parse", "HEAD"], workspaceRoot);
|
|
489
|
+
if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
|
|
490
|
+
const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
|
|
491
|
+
if (!tree || !/^[0-9a-f]{40}$/.test(tree)) refuse("cannot resolve the HEAD tree");
|
|
492
|
+
// Every batch crate and the workspace manifest and lock must match the commit the batch checks are bound to.
|
|
493
|
+
const dirty = git(["status", "--porcelain", "--", ...crates.map((unit) => `crates/${unit}`), "Cargo.toml", "Cargo.lock"], workspaceRoot);
|
|
494
|
+
if (dirty === null) refuse("cannot read git status for the batch");
|
|
495
|
+
if (dirty !== "") refuse(`tree is not clean for the batch crates or the workspace manifest; commit first:\n${dirty}`);
|
|
496
|
+
const registryHas = deps.registryHas ?? defaultRegistryHas;
|
|
497
|
+
const published = (await Promise.all(crates.map(async (unit) => ((await registryHas({ kind: "cargo", name: unit, version: versions.get(unit) })) ? `${unit}@${versions.get(unit)}` : null)))).filter(Boolean);
|
|
498
|
+
if (published.length) refuse(`already on the crates.io registry: ${published.join(", ")}; bump the version or remove them from the batch`);
|
|
499
|
+
log(`[right-release] local publish cargo batch: ${crates.length} crate(s) in ${layers.length} layer(s) at ${head.slice(0, 12)}, concurrency ${concurrency} (local-build grant, darwin-only policy)`);
|
|
500
|
+
|
|
501
|
+
// 3. Batch-level checks, once, at the batch commit. Each is recorded in every crate's evidence.
|
|
502
|
+
const env = testEnv(deps, false);
|
|
503
|
+
const stamp = { commit: head, tree };
|
|
504
|
+
const checks = []; const commands = [];
|
|
505
|
+
const batchCheck = async (name, command, args, crateNames, runEnv = env) => {
|
|
506
|
+
const text = `${command} ${args.join(" ")}`;
|
|
507
|
+
log(`[right-release] batch ${name}: ${text}`);
|
|
508
|
+
const result = await run(command, args, { cwd: workspaceRoot, env: runEnv });
|
|
509
|
+
if (result.error || result.status !== 0) refuse(`batch ${name} failed (${result.error?.message ?? `exit ${result.status}`}): ${text}\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
|
|
510
|
+
checks.push({ name, command: text, ok: true, ...stamp, ...(crateNames ? { crates: crateNames } : {}) });
|
|
511
|
+
commands.push(text);
|
|
512
|
+
};
|
|
513
|
+
await batchCheck("fmt", "cargo", ["fmt", "--all", "--check"]);
|
|
514
|
+
log("[right-release] batch secret-scan: crate directories of the batch");
|
|
515
|
+
const scan = deps.scanSecrets ?? scanCrateForSecrets;
|
|
516
|
+
const violations = crates.flatMap((unit) => scan(path.join(workspaceRoot, "crates", unit)).map((file) => `${unit}: ${file}`));
|
|
517
|
+
if (violations.length) refuse(`batch secret scan rejected: ${violations.join(", ")}`);
|
|
518
|
+
const scanText = `scanCrateForSecrets ${crates.map((unit) => `crates/${unit}`).join(" ")}`;
|
|
519
|
+
checks.push({ name: "secret-scan", command: scanText, ok: true, ...stamp, crates });
|
|
520
|
+
commands.push(scanText);
|
|
521
|
+
const featuresOf = (unit) => (gateConfig?.rust?.[unit]?.featureSets ?? [[]])[0];
|
|
522
|
+
for (const layer of layers) {
|
|
523
|
+
for (const invocation of batchClippyInvocations(layer, { featuresOf, assetGatedOf: isAssetGated })) {
|
|
524
|
+
await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const receipt = { id: `batch-${head.slice(0, 12)}-${sha256(JSON.stringify(layers)).slice(0, 8)}`, ...stamp, crates, checks };
|
|
528
|
+
|
|
529
|
+
// 4. Publish layer by layer. A crate's publish returns only after its registry readback proved the exact
|
|
530
|
+
// checksum visible in the index, so layer N+1 starts only once every layer-N dependency is visible.
|
|
531
|
+
const batch = { head, tree, receipt, commands };
|
|
532
|
+
const results = [];
|
|
533
|
+
for (const [index, layer] of layers.entries()) {
|
|
534
|
+
log(`[right-release] batch layer ${index + 1}/${layers.length}: ${layer.join(", ")}`);
|
|
535
|
+
const queue = [...layer]; const failures = [];
|
|
536
|
+
const worker = async () => {
|
|
537
|
+
while (!failures.length && queue.length) {
|
|
538
|
+
const unit = queue.shift();
|
|
539
|
+
try { results.push(await publishUnit({ ...parsed, kind: "cargo", unit, batch: undefined }, deps, batch)); }
|
|
540
|
+
catch (error) { failures.push({ unit, message: error.message }); }
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, layer.length) }, worker));
|
|
544
|
+
if (failures.length) {
|
|
545
|
+
const done = results.map((r) => `${r.name}@${r.version}`);
|
|
546
|
+
refuse(`batch stopped in layer ${index + 1}/${layers.length}; published so far: ${done.join(", ") || "none"}\n${failures.map((f) => `- ${f.unit}: ${f.message}`).join("\n")}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
log(`[right-release] batch published ${results.length} crate(s): ${results.map((r) => `${r.name}@${r.version}`).join(", ")}`);
|
|
550
|
+
return { kind: "cargo", batch: true, commit: head, tree, results };
|
|
551
|
+
}
|
|
552
|
+
|
|
265
553
|
const realOrSelf = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
|
|
266
554
|
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
267
555
|
const [kind, ...rest] = process.argv.slice(2);
|