@rightkit/release 0.2.174 → 0.4.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 +200 -2
- package/addon-command.mjs +3 -4
- package/android-play-upload.mjs +2 -1
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/build-release.mjs +138 -47
- package/cli/right-release.mjs +40 -5
- package/codesign-environment.mjs +2 -2
- package/command-spec.mjs +80 -0
- package/content-digest.mjs +209 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/development.mjs +17 -6
- package/direct-bootstrap.mjs +5 -6
- package/disk-command.mjs +1 -0
- package/disk-policy.json +6 -2
- package/disk-stable-root.mjs +13 -9
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/github-updater-release.mjs +70 -7
- package/icon-pack.mjs +117 -0
- package/in-place-install.mjs +17 -14
- package/init-command.mjs +116 -0
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +56 -42
- package/ios-asc-credentials.mjs +163 -0
- package/ios-build-context.mjs +129 -0
- package/ios-build-upload.mjs +282 -0
- package/ios-credentials-command.mjs +70 -0
- package/ios-dev.mjs +248 -0
- package/ios-host.mjs +76 -0
- package/ios-lock.mjs +75 -0
- package/ios-package-recipe.mjs +707 -0
- package/ios-testflight.mjs +266 -0
- package/ios-upload.mjs +313 -0
- package/local-publish.mjs +390 -47
- package/macho-approval-hash.mjs +181 -0
- package/managed-cargo.mjs +57 -1
- package/media-sidecar.mjs +20 -4
- package/mirror-root-artifact.mjs +74 -22
- package/native-cargo-layout.mjs +47 -4
- package/npm-license-notices.mjs +159 -0
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +2 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.mjs +66 -16
- package/ownership-repositories.json +1 -1
- package/ownership-scope.mjs +94 -0
- package/ownership.mjs +86 -11
- package/package-recipe-targets.mjs +56 -2
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/preflight.mjs +14 -7
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +83 -5
- package/publish-cargo.mjs +124 -18
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +4 -0
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +62 -24
- package/release.mjs +144 -21
- package/rightkit-ownership.json +46 -2
- package/rightkit-versions.json +380 -189
- package/runtime-budgets.mjs +134 -23
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +634 -69
- package/target-distribution.mjs +5 -0
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/upload-release.mjs +7 -2
- package/web-build.mjs +67 -0
package/local-publish.mjs
CHANGED
|
@@ -1,27 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `right-release publish npm|cargo ... --local`: publish ONE @rightkit npm package or crate from the Mac
|
|
3
|
-
// Adrian's local-build grant (2026-10-14, committed
|
|
4
|
-
// publication gate: it runs the unit's own CI tests
|
|
5
|
-
// scripts/ci/gate.mjs semantics), packs the artifact, writes a
|
|
6
|
-
// (hostPolicy "darwin-only"
|
|
2
|
+
// `right-release publish npm|cargo ... --local`: publish ONE @rightkit npm package or crate from the Mac (npm and
|
|
3
|
+
// crates) or from Windows (crates only) under Adrian's local-build grant (2026-10-14, committed
|
|
4
|
+
// .rightkit-local-build.json). It does not weaken the publication gate: it runs the unit's own CI tests
|
|
5
|
+
// (ci/gate.config.json, ci/asset-gated.json, scripts/ci/gate.mjs semantics), packs the artifact, writes a
|
|
6
|
+
// schema-1 evidence file with one receipt for the publishing host (hostPolicy "darwin-only" on the Mac,
|
|
7
|
+
// "win32-only" on Windows), then calls the EXISTING enforcePublicationGate and the EXISTING publisher
|
|
7
8
|
// (publishGatedTarball for npm, runCargoPublish for cargo). There is no way to skip a test or the evidence.
|
|
9
|
+
//
|
|
10
|
+
// Batch mode (`cargo --crates a,b,c | --crates-file f --local`): fmt, the secret scan and clippy run ONCE for
|
|
11
|
+
// the batch at one commit and tree and are recorded in every crate's evidence as a `batch` receipt that the
|
|
12
|
+
// publication gate validates (publication-gate.mjs batchReceiptProblems); each crate still runs its own tests,
|
|
13
|
+
// all-features check, packaging, gate, publish and registry readback, and writes its own evidence file. Crates
|
|
14
|
+
// of one dependency layer publish concurrently (bounded); layer N+1 starts only after every layer-N crate is
|
|
15
|
+
// visible in the registry index with the verified checksum.
|
|
8
16
|
import crypto from "node:crypto";
|
|
9
17
|
import fs from "node:fs";
|
|
10
18
|
import os from "node:os";
|
|
11
19
|
import path from "node:path";
|
|
12
20
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
21
|
import { spawnSync } from "./background-process.mjs";
|
|
14
|
-
import { runCargoSync } from "./managed-cargo.mjs";
|
|
22
|
+
import { runCargoAsync, runCargoSync, spawnCollect } from "./managed-cargo.mjs";
|
|
15
23
|
import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
|
|
16
|
-
import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
|
|
17
|
-
import { runCargoPublish } from "./publish-cargo.mjs";
|
|
24
|
+
import { GATE_SCHEMA, MAX_CARGO_JOBS, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
|
|
25
|
+
import { runCargoPublish, runCargoReadOnly, scanCrateForSecrets } from "./publish-cargo.mjs";
|
|
18
26
|
import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
|
|
19
|
-
import { readCargoPackage } from "./crate-graph.mjs";
|
|
27
|
+
import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
|
|
20
28
|
import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
|
|
21
29
|
import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
|
|
22
30
|
|
|
23
31
|
export const LOCAL_OPERATOR = "agent (local-build grant)";
|
|
24
32
|
export const LOCAL_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run";
|
|
33
|
+
export const LOCAL_WIN32_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); published from the Windows host, the macOS run is not part of this receipt";
|
|
34
|
+
// The hosts `--local` accepts and the host policy each records in the evidence.
|
|
35
|
+
export const LOCAL_HOST_POLICIES = Object.freeze({ darwin: "darwin-only", win32: "win32-only" });
|
|
36
|
+
const localHostPolicyReason = (platform) => (platform === "win32" ? LOCAL_WIN32_HOST_POLICY_REASON : LOCAL_HOST_POLICY_REASON);
|
|
25
37
|
const STEP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
26
38
|
|
|
27
39
|
export class LocalPublishError extends Error {
|
|
@@ -29,22 +41,121 @@ export class LocalPublishError extends Error {
|
|
|
29
41
|
}
|
|
30
42
|
const refuse = (message) => { throw new LocalPublishError(message); };
|
|
31
43
|
|
|
44
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
45
|
+
const MAX_CONCURRENCY = 8;
|
|
46
|
+
const CRATE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
47
|
+
const JOBS_SUBCOMMANDS = new Set(["test", "clippy", "check", "package"]);
|
|
48
|
+
export function withCargoJobs(args, jobs) {
|
|
49
|
+
return jobs !== undefined && JOBS_SUBCOMMANDS.has(args[0])
|
|
50
|
+
? [args[0], "--jobs", String(jobs), ...args.slice(1)] : args;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const crateList = (text, what) => {
|
|
54
|
+
const names = String(text ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
55
|
+
for (const name of names) if (!CRATE_NAME.test(name)) refuse(`${what}: invalid crate name ${JSON.stringify(name)}`);
|
|
56
|
+
return names;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A crates file: one crate per line, blank lines separate dependency layers (earlier layers publish first),
|
|
61
|
+
* `#` starts a comment. Returns the layers; refuses a bad name, a repeated crate or an empty file.
|
|
62
|
+
*/
|
|
63
|
+
export function parseCrateLayers(text) {
|
|
64
|
+
const layers = []; let current = [];
|
|
65
|
+
const flush = () => { if (current.length) layers.push(current); current = []; };
|
|
66
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
67
|
+
if (raw.trim().startsWith("#")) continue;
|
|
68
|
+
const line = raw.replace(/\s+#.*$/, "").trim();
|
|
69
|
+
if (!line) { flush(); continue; }
|
|
70
|
+
if (!CRATE_NAME.test(line)) refuse(`crates file: invalid crate name ${JSON.stringify(line)}`);
|
|
71
|
+
current.push(line);
|
|
72
|
+
}
|
|
73
|
+
flush();
|
|
74
|
+
const all = layers.flat();
|
|
75
|
+
const repeated = all.find((name, index) => all.indexOf(name) !== index);
|
|
76
|
+
if (repeated) refuse(`crates file names ${repeated} more than once`);
|
|
77
|
+
if (!all.length) refuse("crates file names no crate");
|
|
78
|
+
return layers;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An explicit layering must put every in-batch dependency of a crate in a STRICTLY earlier layer. */
|
|
82
|
+
export function checkCrateLayers(layers, dependsOn) {
|
|
83
|
+
const layerOf = new Map();
|
|
84
|
+
layers.forEach((layer, index) => layer.forEach((name) => layerOf.set(name, index)));
|
|
85
|
+
for (const [name, index] of layerOf) {
|
|
86
|
+
for (const dependency of dependsOn(name)) {
|
|
87
|
+
if (!layerOf.has(dependency)) continue;
|
|
88
|
+
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`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return layers;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Layers derived from the in-batch dependency edges: layer = 1 + the deepest in-batch dependency. */
|
|
95
|
+
export function deriveCrateLayers(crates, dependsOn) {
|
|
96
|
+
const member = new Set(crates);
|
|
97
|
+
const level = new Map(); const visiting = new Set();
|
|
98
|
+
const visit = (name) => {
|
|
99
|
+
if (level.has(name)) return level.get(name);
|
|
100
|
+
if (visiting.has(name)) refuse(`dependency cycle among the batch crates at ${name}`);
|
|
101
|
+
visiting.add(name);
|
|
102
|
+
const depth = Math.max(-1, ...dependsOn(name).filter((dependency) => member.has(dependency) && dependency !== name).map(visit)) + 1;
|
|
103
|
+
visiting.delete(name); level.set(name, depth);
|
|
104
|
+
return depth;
|
|
105
|
+
};
|
|
106
|
+
crates.forEach(visit);
|
|
107
|
+
const layers = [];
|
|
108
|
+
for (const name of crates) (layers[level.get(name)] ??= []).push(name);
|
|
109
|
+
return layers;
|
|
110
|
+
}
|
|
111
|
+
|
|
32
112
|
export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
|
|
33
113
|
const flag = kind === "npm" ? "--package" : "--crate";
|
|
114
|
+
const batchFlags = kind === "cargo" ? ", --crates <a,b,c>, --crates-file <file>, --concurrency <n>, --allow-ignored-units <a,b>" : "";
|
|
34
115
|
let unit; let workspace; let local = false; let allowIgnored = false;
|
|
116
|
+
let qualifyOnly = false; let bundle; let jobsText;
|
|
117
|
+
let crates; let cratesFile; let concurrency = DEFAULT_CONCURRENCY; let allowIgnoredUnits = [];
|
|
35
118
|
for (let i = 0; i < argv.length; i += 1) {
|
|
36
119
|
const arg = argv[i];
|
|
37
120
|
if (arg === "--local") local = true;
|
|
38
121
|
else if (arg === flag) unit = argv[++i];
|
|
39
122
|
else if (arg === "--workspace") workspace = argv[++i];
|
|
123
|
+
else if (kind === "cargo" && arg === "--qualify-only") qualifyOnly = true;
|
|
124
|
+
else if (kind === "cargo" && arg === "--bundle") bundle = argv[++i];
|
|
40
125
|
// Same meaning as publish.yml's allow_ignored: this unit may have ignored tests; the count is recorded.
|
|
41
126
|
else if (arg === "--allow-ignored") allowIgnored = true;
|
|
42
|
-
else
|
|
127
|
+
else if (kind === "cargo" && arg === "--crates") crates = crateList(argv[++i], "--crates");
|
|
128
|
+
else if (kind === "cargo" && arg === "--crates-file") cratesFile = argv[++i];
|
|
129
|
+
else if (kind === "cargo" && arg === "--concurrency") concurrency = Number(argv[++i]);
|
|
130
|
+
else if (kind === "cargo" && arg === "--allow-ignored-units") allowIgnoredUnits = crateList(argv[++i], "--allow-ignored-units");
|
|
131
|
+
else if (kind === "cargo" && arg === "--jobs") jobsText = argv[++i] ?? "";
|
|
132
|
+
else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>${batchFlags}, --workspace <dir>, --allow-ignored, --local)`);
|
|
43
133
|
}
|
|
44
134
|
if (!local) refuse("--local is required");
|
|
135
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) refuse(`--concurrency must be an integer from 1 to ${MAX_CONCURRENCY}`);
|
|
136
|
+
const workspaceRoot = path.resolve(workspace ?? cwd);
|
|
137
|
+
if (qualifyOnly && (crates !== undefined || cratesFile !== undefined)) refuse("--qualify-only currently requires one --crate");
|
|
138
|
+
if (qualifyOnly && !String(bundle ?? "").trim()) refuse("--qualify-only requires --bundle <new-directory>");
|
|
139
|
+
if (bundle !== undefined && !qualifyOnly) refuse("--bundle requires --qualify-only");
|
|
140
|
+
const bundleDir = bundle === undefined ? undefined : path.resolve(cwd, bundle);
|
|
141
|
+
let jobs;
|
|
142
|
+
if (jobsText !== undefined) {
|
|
143
|
+
if (!/^[0-9]+$/.test(jobsText) || Number(jobsText) < 1 || Number(jobsText) > MAX_CARGO_JOBS) refuse(`--jobs must be an integer from 1 to ${MAX_CARGO_JOBS}`);
|
|
144
|
+
if (!qualifyOnly) refuse("--jobs requires --qualify-only");
|
|
145
|
+
jobs = Number(jobsText);
|
|
146
|
+
}
|
|
147
|
+
if (kind === "cargo" && (crates !== undefined || cratesFile !== undefined)) {
|
|
148
|
+
if (unit !== undefined) refuse("--crate cannot be combined with --crates or --crates-file");
|
|
149
|
+
if (crates !== undefined && cratesFile !== undefined) refuse("use --crates or --crates-file, not both");
|
|
150
|
+
if (crates !== undefined && !crates.length) refuse("--crates names no crate");
|
|
151
|
+
if (cratesFile !== undefined && !String(cratesFile ?? "").trim()) refuse("--crates-file needs a file");
|
|
152
|
+
const repeated = crates?.find((name, index) => crates.indexOf(name) !== index);
|
|
153
|
+
if (repeated) refuse(`--crates names ${repeated} more than once`);
|
|
154
|
+
return { kind, unit: undefined, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: { crates, file: cratesFile === undefined ? undefined : path.resolve(cwd, cratesFile) } };
|
|
155
|
+
}
|
|
45
156
|
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:
|
|
157
|
+
if (kind === "cargo" && !CRATE_NAME.test(unit ?? "")) refuse("publish cargo --local requires --crate <name> (or --crates / --crates-file)");
|
|
158
|
+
return { kind, unit, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: undefined, ...(qualifyOnly ? { qualifyOnly, bundleDir } : {}), ...(jobs === undefined ? {} : { jobs }) };
|
|
48
159
|
}
|
|
49
160
|
|
|
50
161
|
function defaultGit(args, cwd) {
|
|
@@ -74,6 +185,40 @@ function defaultRun(command, args, options) {
|
|
|
74
185
|
return command === "cargo" ? runCargoSync(args, settings) : spawnSync(command, args, settings);
|
|
75
186
|
}
|
|
76
187
|
|
|
188
|
+
// Async twin used by batch mode so crates of one layer can overlap their uploads and index waits. The broker
|
|
189
|
+
// still runs builds one at a time; `await` on a synchronous fake works the same.
|
|
190
|
+
function defaultRunAsync(command, args, options) {
|
|
191
|
+
const settings = { encoding: "utf8", maxBuffer: 512 * 1024 * 1024, timeout: STEP_TIMEOUT_MS, windowsHide: true, ...options };
|
|
192
|
+
return command === "cargo" ? runCargoAsync(args, settings) : spawnCollect(command, args, settings);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* `execute` for runCargoPublish in batch mode: the async Cargo runner, with the output of a step that would
|
|
197
|
+
* inherit stdio echoed line by line under the crate name (concurrent crates would otherwise interleave raw).
|
|
198
|
+
*/
|
|
199
|
+
export function batchCargoExecute(label, { run = runCargoAsync, out = process.stdout, err = process.stderr } = {}) {
|
|
200
|
+
return async (_command, args, settings) => {
|
|
201
|
+
const { stdio, ...rest } = settings;
|
|
202
|
+
const result = await run(args, { ...rest, timeout: STEP_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
|
|
203
|
+
if (stdio === "inherit") {
|
|
204
|
+
for (const [stream, text] of [[out, result.stdout], [err, result.stderr]]) {
|
|
205
|
+
for (const line of String(text ?? "").split(/\r?\n/)) if (line) stream.write(`[${label}] ${line}\n`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Registry versions-file writers (recordPublished, stageSourceVersions) read-modify-write one shared JSON file
|
|
213
|
+
// and its consumer copies. They are synchronous, but the lock keeps the whole write one unit even if a writer
|
|
214
|
+
// becomes async, so concurrent crates of a layer can never interleave two updates.
|
|
215
|
+
let versionsTail = Promise.resolve();
|
|
216
|
+
const withVersionsLock = (task) => {
|
|
217
|
+
const result = versionsTail.then(task);
|
|
218
|
+
versionsTail = result.catch(() => {});
|
|
219
|
+
return result;
|
|
220
|
+
};
|
|
221
|
+
|
|
77
222
|
async function defaultLoadWorkspaceModules(workspaceRoot) {
|
|
78
223
|
const load = (file) => import(pathToFileURL(path.join(workspaceRoot, "scripts", "ci", file)).href);
|
|
79
224
|
const [results, upload] = await Promise.all([load("test-results.mjs"), load("upload.mjs")]);
|
|
@@ -92,7 +237,7 @@ export function allFeatureCheckSets(entry = {}) {
|
|
|
92
237
|
}
|
|
93
238
|
|
|
94
239
|
/** 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 }) {
|
|
240
|
+
export function localTestPlan({ kind, unit, dir, config, manifest, batch = false, platform = "darwin" }) {
|
|
96
241
|
if (kind === "npm") {
|
|
97
242
|
if (!manifest?.scripts?.test) refuse(`${unit} has no test script; --local never publishes an untested package`);
|
|
98
243
|
return {
|
|
@@ -104,33 +249,88 @@ export function localTestPlan({ kind, unit, dir, config, manifest }) {
|
|
|
104
249
|
};
|
|
105
250
|
}
|
|
106
251
|
const entry = config?.rust?.[unit] ?? {};
|
|
107
|
-
|
|
252
|
+
// gate.config.json names hosts "macos" and "windows" (scripts/ci/gate.mjs HOST).
|
|
253
|
+
const gateHost = platform === "win32" ? "windows" : "macos";
|
|
254
|
+
if (entry.os && !entry.os.includes(gateHost)) refuse(`${unit} is restricted to ${entry.os.join(", ")} by ci/gate.config.json; it cannot be qualified on this ${platform === "win32" ? "Windows host" : "Mac"}`);
|
|
108
255
|
const featureSets = entry.featureSets ?? [[]];
|
|
256
|
+
// Stream Cargo artifact records to the broker; libtest summaries remain plain text for ResultScanner.
|
|
109
257
|
const steps = [];
|
|
110
258
|
for (const features of featureSets) {
|
|
111
|
-
steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--show-output"], test: true });
|
|
112
|
-
if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
|
|
259
|
+
steps.push({ command: "cargo", args: ["test", "-p", unit, "--message-format=json-render-diagnostics", ...features, "--no-fail-fast", "--", "--show-output"], test: true });
|
|
260
|
+
if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, "--message-format=json-render-diagnostics", ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
|
|
113
261
|
}
|
|
114
|
-
|
|
115
|
-
|
|
262
|
+
// Batch mode lints the whole batch once (batchClippyInvocations) and records it as a batch receipt.
|
|
263
|
+
if (!batch) steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...featureSets[0], "--", "-D", "warnings"], test: false });
|
|
264
|
+
for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--message-format=json-render-diagnostics", "--all-targets", ...features], test: false });
|
|
116
265
|
return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
|
|
117
266
|
}
|
|
118
267
|
|
|
268
|
+
/**
|
|
269
|
+
* The clippy invocations that replace each crate's own `cargo clippy -p <crate> --no-deps --all-targets
|
|
270
|
+
* <featureSets[0]> -- -D warnings`: same flags and lint level, so the same lints on the same code. Crates
|
|
271
|
+
* whose first feature set is empty or `--all-features` share one `-p a -p b` invocation (the flags mean the
|
|
272
|
+
* same for every crate); any other feature set runs per crate, because `--features x` must not reach a crate
|
|
273
|
+
* that lacks x. The caller passes ONE dependency layer: no selected crate depends on another, so `-p` does not
|
|
274
|
+
* unify extra features into a selected crate and each is linted with exactly the features it was before.
|
|
275
|
+
* Crates with a different RIGHTKIT_REQUIRE_ASSETS (asset-gated) stay apart so the environment is unchanged.
|
|
276
|
+
*/
|
|
277
|
+
export function batchClippyInvocations(layer, { featuresOf = () => [], assetGatedOf = () => false } = {}) {
|
|
278
|
+
const groups = new Map();
|
|
279
|
+
for (const unit of layer) {
|
|
280
|
+
const features = featuresOf(unit) ?? [];
|
|
281
|
+
const shared = features.length === 0 || (features.length === 1 && features[0] === "--all-features");
|
|
282
|
+
const key = shared ? JSON.stringify([features, assetGatedOf(unit)]) : `unit:${unit}`;
|
|
283
|
+
if (!groups.has(key)) groups.set(key, { crates: [], features, assetGated: assetGatedOf(unit) });
|
|
284
|
+
groups.get(key).crates.push(unit);
|
|
285
|
+
}
|
|
286
|
+
return [...groups.values()].map(({ crates, features, assetGated }) => ({
|
|
287
|
+
crates, assetGated,
|
|
288
|
+
args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
|
|
119
292
|
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
|
120
293
|
const tarballName = (name, version) => `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`;
|
|
121
294
|
const tail = (text, lines = 40) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
|
|
122
295
|
|
|
296
|
+
// Tests run under hosted-CI conditions: no inherited credentials (a managed host refuses a token from the
|
|
297
|
+
// environment) and no sibling product checkouts (CI holds rightkit alone).
|
|
298
|
+
function testEnv(deps, isAssetGated) {
|
|
299
|
+
const inherited = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([key]) => !/(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)/i.test(key)));
|
|
300
|
+
return { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function assertLocalHost(deps, workspaceRoot, kind) {
|
|
304
|
+
const platform = deps.platform ?? process.platform;
|
|
305
|
+
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}`);
|
|
306
|
+
if (!Object.hasOwn(LOCAL_HOST_POLICIES, platform)) refuse(`--local publishes from the Mac only (host policy: darwin-only) or from Windows (host policy: win32-only); this host is ${platform}`);
|
|
307
|
+
// The npm path reads the registry token from the macOS Keychain and ships a Developer-ID-signed helper: Mac only.
|
|
308
|
+
if (kind === "npm" && platform !== "darwin") refuse(`publish npm --local needs the macOS Keychain npm token and runs on the Mac only; Windows publishes crates (publish cargo --local); this host is ${platform}`);
|
|
309
|
+
return platform;
|
|
310
|
+
}
|
|
311
|
+
|
|
123
312
|
export async function runLocalPublish(kind, argv, deps = {}) {
|
|
313
|
+
const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
|
|
314
|
+
if (parsed.batch) return runLocalPublishBatch(parsed, deps);
|
|
315
|
+
return publishUnit(parsed, deps, null);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Qualify and publish ONE unit. `batch` (cargo batch mode only) is `{ head, tree, receipt, commands }`: the
|
|
320
|
+
* batch-level fmt, secret scan and clippy already ran at `head`/`tree`, so this unit skips its own clippy and
|
|
321
|
+
* copies the receipt into its evidence; everything else (tests, all-features check, packaging, the gate, the
|
|
322
|
+
* publish and the registry readback) runs for this unit exactly as in single mode.
|
|
323
|
+
*/
|
|
324
|
+
async function publishUnit(parsed, deps, batch) {
|
|
124
325
|
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
125
|
-
const { unit, allowIgnored, workspaceRoot } =
|
|
126
|
-
const platform = deps.platform ?? process.platform;
|
|
326
|
+
const { kind, unit, allowIgnored, workspaceRoot } = parsed;
|
|
127
327
|
const git = deps.git ?? defaultGit;
|
|
128
328
|
const readJson = deps.readJson ?? defaultReadJson;
|
|
129
|
-
const run = deps.run ?? defaultRun;
|
|
329
|
+
const run = deps.run ?? (batch ? defaultRunAsync : defaultRun);
|
|
130
330
|
|
|
131
331
|
// 1. Preconditions, fail closed.
|
|
132
|
-
|
|
133
|
-
if (
|
|
332
|
+
const platform = assertLocalHost(deps, workspaceRoot, kind);
|
|
333
|
+
if (parsed.qualifyOnly && fs.existsSync(parsed.bundleDir)) refuse(`qualification bundle already exists: ${parsed.bundleDir}`);
|
|
134
334
|
const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
|
|
135
335
|
const unitPath = path.join(workspaceRoot, dir);
|
|
136
336
|
if (!fs.existsSync(unitPath)) refuse(`unit directory not found: ${unitPath}`);
|
|
@@ -148,33 +348,45 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
148
348
|
if (typeof version !== "string" || !version.trim()) refuse(`${unit} has no literal version`);
|
|
149
349
|
const head = git(["rev-parse", "HEAD"], workspaceRoot);
|
|
150
350
|
if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
|
|
151
|
-
|
|
351
|
+
if (batch) {
|
|
352
|
+
// The batch receipt is valid for one commit and tree only; a moved HEAD would make it describe other code.
|
|
353
|
+
if (head !== batch.head) refuse(`${unit}: HEAD moved to ${head} during the batch (batch commit ${batch.head})`);
|
|
354
|
+
const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
|
|
355
|
+
if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
|
|
356
|
+
}
|
|
357
|
+
const dirty = git(["status", "--porcelain", "--", dir, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
|
|
152
358
|
if (dirty === null) refuse(`cannot read git status for ${dir}`);
|
|
153
359
|
if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
|
|
154
360
|
if (await (deps.registryHas ?? defaultRegistryHas)({ kind, name: unit, version })) refuse(`${unit}@${version} is already on the ${kind === "npm" ? "npm" : "crates.io"} registry; bump the version`);
|
|
155
|
-
log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant,
|
|
361
|
+
log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
|
|
156
362
|
|
|
157
363
|
// 2. The unit's own tests, exactly as the CI gate defines them.
|
|
158
364
|
const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
|
|
159
365
|
const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
|
|
160
366
|
const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
|
|
161
367
|
const isAssetGated = Object.hasOwn(gatedSection, unit);
|
|
162
|
-
const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest });
|
|
368
|
+
const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch), platform });
|
|
369
|
+
if (parsed.qualifyOnly) {
|
|
370
|
+
const violations = (deps.scanSecrets ?? scanCrateForSecrets)(unitPath);
|
|
371
|
+
if (violations.length) refuse(`qualification secret scan rejected: ${violations.join(", ")}`);
|
|
372
|
+
plan.steps.unshift({ command: "cargo", args: ["fmt", "-p", unit, "--check"], test: false });
|
|
373
|
+
}
|
|
163
374
|
const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
|
|
164
375
|
const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
const
|
|
376
|
+
const env = testEnv(deps, isAssetGated);
|
|
377
|
+
// Ignored tests are refused unless the unit is named: --allow-ignored / --allow-ignored-units, or a
|
|
378
|
+
// written reason in ci/gate.config.json (rust.<crate>.allowIgnored). The count stays in the evidence.
|
|
379
|
+
const configReason = kind === "cargo" && typeof gateConfig?.rust?.[unit]?.allowIgnored === "string" ? gateConfig.rust[unit].allowIgnored.trim() : "";
|
|
380
|
+
const ignoredAllowed = Boolean(allowIgnored || parsed.allowIgnoredUnits?.includes(unit) || configReason);
|
|
381
|
+
const steps = parsed.qualifyOnly ? [{ command: `scanCrateForSecrets ${dir}`, ok: true }] : [];
|
|
170
382
|
for (const step of plan.steps) {
|
|
171
383
|
const commandText = `${step.command} ${step.args.join(" ")}`;
|
|
172
384
|
log(`[right-release] ${plan.id}: ${commandText}`);
|
|
173
|
-
const result = run(step.command, step.args, { cwd: workspaceRoot, env });
|
|
385
|
+
const result = await run(step.command, step.command === "cargo" ? withCargoJobs(step.args, parsed.jobs) : step.args, { cwd: workspaceRoot, env });
|
|
174
386
|
const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
|
|
175
387
|
if (result.error || result.status !== 0) {
|
|
176
388
|
steps.push(row);
|
|
177
|
-
refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\n${
|
|
389
|
+
refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\nstdout:\n${result.stdout ?? ""}\nstderr:\n${result.stderr ?? ""}`);
|
|
178
390
|
}
|
|
179
391
|
row.ok = true;
|
|
180
392
|
if (row.isTest) {
|
|
@@ -185,7 +397,7 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
185
397
|
}
|
|
186
398
|
steps.push(row);
|
|
187
399
|
}
|
|
188
|
-
const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored:
|
|
400
|
+
const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored: ignoredAllowed ? [unit] : [], allowMissingAssets: isAssetGated ? [unit] : [] });
|
|
189
401
|
if (verdict.status === "fail" || steps.some((s) => s.failed > 0)) refuse(`${plan.id} not qualified: ${verdict.problems.join("; ") || "failed tests reported"}`);
|
|
190
402
|
if (!(verdict.executed > 0)) refuse(`${plan.id} executed no test`);
|
|
191
403
|
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,40 +416,52 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
204
416
|
if (kind === "npm") {
|
|
205
417
|
// The feed a tarball ships must list the version it ships with (#319): stage it before packing.
|
|
206
418
|
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 });
|
|
419
|
+
if (fs.existsSync(feedFile)) await withVersionsLock(() => (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log }));
|
|
208
420
|
const args = ["--dir", dir, "pack", "--pack-destination", scratch];
|
|
209
421
|
packCommands.push(`pnpm ${args.join(" ")}`);
|
|
210
|
-
const packed = run("pnpm", args, { cwd: workspaceRoot, env });
|
|
422
|
+
const packed = await run("pnpm", args, { cwd: workspaceRoot, env });
|
|
211
423
|
if (packed.error || packed.status !== 0) refuse(`pnpm pack failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
|
|
212
424
|
artifactFile = path.join(scratch, tarballName(unit, version));
|
|
213
425
|
if (!fs.existsSync(artifactFile)) refuse(`pnpm pack did not produce ${path.basename(artifactFile)}`);
|
|
214
426
|
const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
|
|
215
427
|
if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
|
|
216
428
|
} else {
|
|
217
|
-
|
|
218
|
-
|
|
429
|
+
// A combined publish verifies once in `cargo publish`; detached qualification verifies here before
|
|
430
|
+
// its evidence leaves this host, so the publishing host can reuse that exact archive's proof.
|
|
431
|
+
const packageArgs = ["package", "--locked", ...(parsed.qualifyOnly ? [] : ["--no-verify"]), "-p", unit];
|
|
432
|
+
packCommands.push(`cargo ${packageArgs.join(" ")}`);
|
|
433
|
+
const packed = await run("cargo", withCargoJobs(packageArgs, parsed.jobs), { cwd: workspaceRoot, env });
|
|
219
434
|
if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
|
|
220
|
-
|
|
221
|
-
const
|
|
435
|
+
if (parsed.qualifyOnly) steps.push({ command: `cargo ${packageArgs.join(" ")}`, ok: true });
|
|
436
|
+
const metadataArgs = ["metadata", "--offline", "--format-version", "1", "--no-deps"];
|
|
437
|
+
const meta = await runCargoReadOnly(metadataArgs, { cwd: workspaceRoot, env }, (_command, args, settings) => run("cargo", args, settings),
|
|
438
|
+
(failed) => log(`[right-release] offline Cargo metadata missed cache; retrying online once\n${failed.stdout ?? ""}${failed.stderr ? `\n${failed.stderr}` : ""}`));
|
|
439
|
+
const actualMetadataArgs = meta.usedOfflineFallback ? metadataArgs.filter((arg) => arg !== "--offline") : metadataArgs;
|
|
440
|
+
packCommands.push(`cargo ${actualMetadataArgs.join(" ")}`);
|
|
222
441
|
let targetDir;
|
|
223
442
|
try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
|
|
224
|
-
if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir))
|
|
443
|
+
if (meta.error || meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) {
|
|
444
|
+
refuse(`cannot read the Cargo target directory\n${tail(`${meta.error?.message ?? ""}\n${meta.stdout ?? ""}\n${meta.stderr ?? ""}`)}`);
|
|
445
|
+
}
|
|
225
446
|
artifactFile = path.join(targetDir, "package", `${unit}-${version}.crate`);
|
|
226
447
|
if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
|
|
227
448
|
}
|
|
228
449
|
const artifactSha256 = sha256(fs.readFileSync(artifactFile));
|
|
229
450
|
|
|
230
451
|
// 4. Evidence in the normal schema, then the EXISTING gate.
|
|
231
|
-
const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands];
|
|
452
|
+
const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands, ...(batch ? batch.commands : [])];
|
|
232
453
|
const timestamp = (deps.now ?? (() => new Date().toISOString()))();
|
|
233
454
|
const evidence = {
|
|
234
455
|
schema: GATE_SCHEMA, kind, name: unit, version, commit: head, artifactSha256,
|
|
235
|
-
hostPolicy:
|
|
456
|
+
hostPolicy: LOCAL_HOST_POLICIES[platform], hostPolicyReason: localHostPolicyReason(platform),
|
|
236
457
|
runs: [{
|
|
237
|
-
host: { os:
|
|
458
|
+
host: { os: platform, arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
|
|
238
459
|
command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
|
|
239
|
-
passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored,
|
|
460
|
+
passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored: ignoredAllowed,
|
|
461
|
+
...(configReason ? { allowIgnoredReason: configReason } : {}),
|
|
240
462
|
requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
|
|
463
|
+
...(parsed.jobs === undefined ? {} : { jobs: parsed.jobs }),
|
|
464
|
+
...(batch ? { batch: structuredClone(batch.receipt) } : {}),
|
|
241
465
|
steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
|
|
242
466
|
}],
|
|
243
467
|
};
|
|
@@ -248,12 +472,28 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
248
472
|
const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath };
|
|
249
473
|
(deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
|
|
250
474
|
|
|
475
|
+
if (parsed.qualifyOnly) {
|
|
476
|
+
// Keep bytes and their normal gate evidence together outside the checkout. Never overwrite a bundle.
|
|
477
|
+
fs.mkdirSync(parsed.bundleDir, { recursive: false });
|
|
478
|
+
const bundledArtifact = path.join(parsed.bundleDir, path.basename(artifactFile));
|
|
479
|
+
const bundledEvidence = path.join(parsed.bundleDir, "gate.json");
|
|
480
|
+
fs.copyFileSync(artifactFile, bundledArtifact, fs.constants.COPYFILE_EXCL);
|
|
481
|
+
if (sha256(fs.readFileSync(bundledArtifact)) !== artifactSha256) refuse("qualification archive changed while copying");
|
|
482
|
+
fs.copyFileSync(evidencePath, bundledEvidence, fs.constants.COPYFILE_EXCL);
|
|
483
|
+
log(`[right-release] qualified ${unit}@${version}; bundle ${parsed.bundleDir}; publish cargo --crate ${unit} --from-evidence ${bundledEvidence}`);
|
|
484
|
+
return { kind, name: unit, version, commit: head, artifactSha256, evidencePath: bundledEvidence, artifactFile: bundledArtifact, qualifiedOnly: true };
|
|
485
|
+
}
|
|
486
|
+
|
|
251
487
|
// 5. The EXISTING publish path.
|
|
252
488
|
if (kind === "npm") {
|
|
253
489
|
(deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
|
|
254
490
|
} else {
|
|
255
|
-
|
|
256
|
-
|
|
491
|
+
// trustEvidenceSteps: the evidence just written (and validated by the gate above) proves this commit's tests
|
|
492
|
+
// and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
|
|
493
|
+
// publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
|
|
494
|
+
const execute = deps.cargoExecute ?? (batch ? batchCargoExecute(unit) : undefined);
|
|
495
|
+
const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath, trustEvidenceSteps: true, verifyOnPublish: true, offlineReadOnly: true }, execute, deps.cargoDeps ?? {});
|
|
496
|
+
await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }));
|
|
257
497
|
}
|
|
258
498
|
log(`[right-release] published ${unit}@${version}`);
|
|
259
499
|
return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
|
|
@@ -262,6 +502,109 @@ export async function runLocalPublish(kind, argv, deps = {}) {
|
|
|
262
502
|
}
|
|
263
503
|
}
|
|
264
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Batch publish (cargo only): validate the whole batch and run fmt, the secret scan and clippy once at one
|
|
507
|
+
* commit and tree, failing the batch closed before anything is uploaded; then publish layer by layer, the
|
|
508
|
+
* crates of a layer concurrently (bounded by --concurrency). A failure stops new work: no crate of a later
|
|
509
|
+
* layer starts, crates of the failing layer that were already running finish, and the refusal lists what
|
|
510
|
+
* published. Re-run with the published crates removed from the list.
|
|
511
|
+
*/
|
|
512
|
+
async function runLocalPublishBatch(parsed, deps) {
|
|
513
|
+
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
514
|
+
const { workspaceRoot, concurrency } = parsed;
|
|
515
|
+
const git = deps.git ?? defaultGit;
|
|
516
|
+
const readJson = deps.readJson ?? defaultReadJson;
|
|
517
|
+
const run = deps.run ?? defaultRunAsync;
|
|
518
|
+
const platform = assertLocalHost(deps, workspaceRoot, "cargo");
|
|
519
|
+
|
|
520
|
+
// 1. The crate list and its dependency layers.
|
|
521
|
+
const requested = parsed.batch.file ? parseCrateLayers((deps.readText ?? ((file) => fs.readFileSync(file, "utf8")))(parsed.batch.file)) : [parsed.batch.crates];
|
|
522
|
+
const crates = requested.flat();
|
|
523
|
+
const dependenciesOf = deps.batchDependencies ?? ((unit) => {
|
|
524
|
+
const members = new Set(crates);
|
|
525
|
+
return pathDependencyDirs(path.join(workspaceRoot, "crates", unit)).map((dir) => path.basename(dir)).filter((name) => members.has(name) && name !== unit);
|
|
526
|
+
});
|
|
527
|
+
const layers = parsed.batch.file ? checkCrateLayers(requested, dependenciesOf) : deriveCrateLayers(crates, dependenciesOf);
|
|
528
|
+
|
|
529
|
+
// 2. Preconditions for every crate before anything runs, fail closed.
|
|
530
|
+
const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
|
|
531
|
+
const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
|
|
532
|
+
const isAssetGated = (unit) => Object.hasOwn(assetGated.crates ?? {}, unit);
|
|
533
|
+
const versions = new Map();
|
|
534
|
+
for (const unit of crates) {
|
|
535
|
+
const dir = `crates/${unit}`;
|
|
536
|
+
if (!fs.existsSync(path.join(workspaceRoot, dir))) refuse(`unit directory not found: ${path.join(workspaceRoot, dir)}`);
|
|
537
|
+
const pkg = (deps.readCargoPackage ?? readCargoPackage)(path.join(workspaceRoot, dir));
|
|
538
|
+
if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
|
|
539
|
+
if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
|
|
540
|
+
versions.set(unit, pkg.version);
|
|
541
|
+
localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true, platform }); // refuses an OS-restricted crate now, not mid-batch
|
|
542
|
+
}
|
|
543
|
+
const head = git(["rev-parse", "HEAD"], workspaceRoot);
|
|
544
|
+
if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
|
|
545
|
+
const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
|
|
546
|
+
if (!tree || !/^[0-9a-f]{40}$/.test(tree)) refuse("cannot resolve the HEAD tree");
|
|
547
|
+
// Every batch crate and the workspace manifest and lock must match the commit the batch checks are bound to.
|
|
548
|
+
const dirty = git(["status", "--porcelain", "--", ...crates.map((unit) => `crates/${unit}`), "Cargo.toml", "Cargo.lock"], workspaceRoot);
|
|
549
|
+
if (dirty === null) refuse("cannot read git status for the batch");
|
|
550
|
+
if (dirty !== "") refuse(`tree is not clean for the batch crates or the workspace manifest; commit first:\n${dirty}`);
|
|
551
|
+
const registryHas = deps.registryHas ?? defaultRegistryHas;
|
|
552
|
+
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);
|
|
553
|
+
if (published.length) refuse(`already on the crates.io registry: ${published.join(", ")}; bump the version or remove them from the batch`);
|
|
554
|
+
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, ${LOCAL_HOST_POLICIES[platform]} policy)`);
|
|
555
|
+
|
|
556
|
+
// 3. Batch-level checks, once, at the batch commit. Each is recorded in every crate's evidence.
|
|
557
|
+
const env = testEnv(deps, false);
|
|
558
|
+
const stamp = { commit: head, tree };
|
|
559
|
+
const checks = []; const commands = [];
|
|
560
|
+
const batchCheck = async (name, command, args, crateNames, runEnv = env) => {
|
|
561
|
+
const text = `${command} ${args.join(" ")}`;
|
|
562
|
+
log(`[right-release] batch ${name}: ${text}`);
|
|
563
|
+
const result = await run(command, args, { cwd: workspaceRoot, env: runEnv });
|
|
564
|
+
if (result.error || result.status !== 0) refuse(`batch ${name} failed (${result.error?.message ?? `exit ${result.status}`}): ${text}\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
|
|
565
|
+
checks.push({ name, command: text, ok: true, ...stamp, ...(crateNames ? { crates: crateNames } : {}) });
|
|
566
|
+
commands.push(text);
|
|
567
|
+
};
|
|
568
|
+
await batchCheck("fmt", "cargo", ["fmt", "--all", "--check"]);
|
|
569
|
+
log("[right-release] batch secret-scan: crate directories of the batch");
|
|
570
|
+
const scan = deps.scanSecrets ?? scanCrateForSecrets;
|
|
571
|
+
const violations = crates.flatMap((unit) => scan(path.join(workspaceRoot, "crates", unit)).map((file) => `${unit}: ${file}`));
|
|
572
|
+
if (violations.length) refuse(`batch secret scan rejected: ${violations.join(", ")}`);
|
|
573
|
+
const scanText = `scanCrateForSecrets ${crates.map((unit) => `crates/${unit}`).join(" ")}`;
|
|
574
|
+
checks.push({ name: "secret-scan", command: scanText, ok: true, ...stamp, crates });
|
|
575
|
+
commands.push(scanText);
|
|
576
|
+
const featuresOf = (unit) => (gateConfig?.rust?.[unit]?.featureSets ?? [[]])[0];
|
|
577
|
+
for (const layer of layers) {
|
|
578
|
+
for (const invocation of batchClippyInvocations(layer, { featuresOf, assetGatedOf: isAssetGated })) {
|
|
579
|
+
await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const receipt = { id: `batch-${head.slice(0, 12)}-${sha256(JSON.stringify(layers)).slice(0, 8)}`, ...stamp, crates, checks };
|
|
583
|
+
|
|
584
|
+
// 4. Publish layer by layer. A crate's publish returns only after its registry readback proved the exact
|
|
585
|
+
// checksum visible in the index, so layer N+1 starts only once every layer-N dependency is visible.
|
|
586
|
+
const batch = { head, tree, receipt, commands };
|
|
587
|
+
const results = [];
|
|
588
|
+
for (const [index, layer] of layers.entries()) {
|
|
589
|
+
log(`[right-release] batch layer ${index + 1}/${layers.length}: ${layer.join(", ")}`);
|
|
590
|
+
const queue = [...layer]; const failures = [];
|
|
591
|
+
const worker = async () => {
|
|
592
|
+
while (!failures.length && queue.length) {
|
|
593
|
+
const unit = queue.shift();
|
|
594
|
+
try { results.push(await publishUnit({ ...parsed, kind: "cargo", unit, batch: undefined }, deps, batch)); }
|
|
595
|
+
catch (error) { failures.push({ unit, message: error.message }); }
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, layer.length) }, worker));
|
|
599
|
+
if (failures.length) {
|
|
600
|
+
const done = results.map((r) => `${r.name}@${r.version}`);
|
|
601
|
+
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")}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
log(`[right-release] batch published ${results.length} crate(s): ${results.map((r) => `${r.name}@${r.version}`).join(", ")}`);
|
|
605
|
+
return { kind: "cargo", batch: true, commit: head, tree, results };
|
|
606
|
+
}
|
|
607
|
+
|
|
265
608
|
const realOrSelf = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
|
|
266
609
|
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
267
610
|
const [kind, ...rest] = process.argv.slice(2);
|