@rightkit/release 0.3.0 → 0.4.1
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 +74 -10
- package/addon-command.mjs +3 -4
- package/adoption-evidence.mjs +650 -0
- package/android-play-upload.mjs +2 -1
- package/apple-profile-create.mjs +113 -10
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/binary-license-notices.mjs +161 -0
- package/build-release.mjs +138 -47
- package/cargo-license-notices.mjs +111 -0
- package/cli/right-release.mjs +21 -2
- package/codesign-environment.mjs +2 -2
- package/content-digest.mjs +209 -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/github-updater-release.mjs +70 -7
- package/in-place-install.mjs +17 -14
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +21 -40
- 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 +222 -53
- package/managed-cargo.mjs +8 -0
- package/mirror-root-artifact.mjs +5 -1
- package/native-cargo-layout.mjs +47 -4
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +3 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.json +117 -67
- package/ownership-mandatory-set.mjs +141 -21
- package/ownership-repositories.json +1 -1
- package/ownership-scope.mjs +94 -0
- package/ownership.mjs +49 -7
- package/package-recipe-targets.mjs +41 -1
- package/package.json +1 -1
- package/pipeline-admission.mjs +375 -0
- package/pipeline-config.mjs +100 -0
- package/pipeline-generators.json +4 -0
- package/pipeline-generators.mjs +137 -0
- package/pipeline-scan.mjs +285 -0
- package/pipeline-tools.mjs +171 -0
- package/preflight.mjs +14 -7
- package/production-mode.mjs +42 -0
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +94 -32
- package/publish-cargo.mjs +117 -25
- package/publish-swift.mjs +12 -3
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +8 -0
- package/release-config.mjs +330 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +88 -21
- package/release.mjs +82 -20
- package/rightkit-ownership.json +1 -1
- package/rightkit-sync.mjs +8 -0
- package/rightkit-versions.json +428 -194
- package/runtime-budget-crates.mjs +14 -0
- package/runtime-budgets.mjs +248 -47
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +207 -40
- package/swap-helper-bundle.mjs +7 -3
- package/target-distribution.mjs +5 -0
- package/upload-release.mjs +7 -2
- package/bin/darwin/rightkit-swap.sign.json +0 -6
package/local-publish.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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.
|
|
8
9
|
//
|
|
9
10
|
// Batch mode (`cargo --crates a,b,c | --crates-file f --local`): fmt, the secret scan and clippy run ONCE for
|
|
@@ -20,15 +21,19 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
20
21
|
import { spawnSync } from "./background-process.mjs";
|
|
21
22
|
import { runCargoAsync, runCargoSync, spawnCollect } from "./managed-cargo.mjs";
|
|
22
23
|
import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
|
|
23
|
-
import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
|
|
24
|
-
import { runCargoPublish, scanCrateForSecrets } 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";
|
|
25
26
|
import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
|
|
26
27
|
import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
|
|
27
|
-
import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
|
|
28
|
+
import { recordPublished, stageSourceVersions, workspaceFeedOptions } from "./rightkit-sync.mjs";
|
|
28
29
|
import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
|
|
29
30
|
|
|
30
31
|
export const LOCAL_OPERATOR = "agent (local-build grant)";
|
|
31
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);
|
|
32
37
|
const STEP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
33
38
|
|
|
34
39
|
export class LocalPublishError extends Error {
|
|
@@ -39,6 +44,11 @@ const refuse = (message) => { throw new LocalPublishError(message); };
|
|
|
39
44
|
export const DEFAULT_CONCURRENCY = 4;
|
|
40
45
|
const MAX_CONCURRENCY = 8;
|
|
41
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
|
+
}
|
|
42
52
|
|
|
43
53
|
const crateList = (text, what) => {
|
|
44
54
|
const names = String(text ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
@@ -103,23 +113,37 @@ export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
|
|
|
103
113
|
const flag = kind === "npm" ? "--package" : "--crate";
|
|
104
114
|
const batchFlags = kind === "cargo" ? ", --crates <a,b,c>, --crates-file <file>, --concurrency <n>, --allow-ignored-units <a,b>" : "";
|
|
105
115
|
let unit; let workspace; let local = false; let allowIgnored = false;
|
|
116
|
+
let qualifyOnly = false; let bundle; let jobsText;
|
|
106
117
|
let crates; let cratesFile; let concurrency = DEFAULT_CONCURRENCY; let allowIgnoredUnits = [];
|
|
107
118
|
for (let i = 0; i < argv.length; i += 1) {
|
|
108
119
|
const arg = argv[i];
|
|
109
120
|
if (arg === "--local") local = true;
|
|
110
121
|
else if (arg === flag) unit = argv[++i];
|
|
111
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];
|
|
112
125
|
// Same meaning as publish.yml's allow_ignored: this unit may have ignored tests; the count is recorded.
|
|
113
126
|
else if (arg === "--allow-ignored") allowIgnored = true;
|
|
114
127
|
else if (kind === "cargo" && arg === "--crates") crates = crateList(argv[++i], "--crates");
|
|
115
128
|
else if (kind === "cargo" && arg === "--crates-file") cratesFile = argv[++i];
|
|
116
129
|
else if (kind === "cargo" && arg === "--concurrency") concurrency = Number(argv[++i]);
|
|
117
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] ?? "";
|
|
118
132
|
else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>${batchFlags}, --workspace <dir>, --allow-ignored, --local)`);
|
|
119
133
|
}
|
|
120
134
|
if (!local) refuse("--local is required");
|
|
121
135
|
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) refuse(`--concurrency must be an integer from 1 to ${MAX_CONCURRENCY}`);
|
|
122
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
|
+
}
|
|
123
147
|
if (kind === "cargo" && (crates !== undefined || cratesFile !== undefined)) {
|
|
124
148
|
if (unit !== undefined) refuse("--crate cannot be combined with --crates or --crates-file");
|
|
125
149
|
if (crates !== undefined && cratesFile !== undefined) refuse("use --crates or --crates-file, not both");
|
|
@@ -131,7 +155,7 @@ export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
|
|
|
131
155
|
}
|
|
132
156
|
if (kind === "npm" && !/^@rightkit\/[a-z0-9-]+$/.test(unit ?? "")) refuse("publish npm --local requires --package @rightkit/<name>");
|
|
133
157
|
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 };
|
|
158
|
+
return { kind, unit, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: undefined, ...(qualifyOnly ? { qualifyOnly, bundleDir } : {}), ...(jobs === undefined ? {} : { jobs }) };
|
|
135
159
|
}
|
|
136
160
|
|
|
137
161
|
function defaultGit(args, cwd) {
|
|
@@ -212,8 +236,101 @@ export function allFeatureCheckSets(entry = {}) {
|
|
|
212
236
|
return entry.checkFeatureSets ?? [["--all-features"]];
|
|
213
237
|
}
|
|
214
238
|
|
|
239
|
+
const CONSUMER_ROW_KEYS = new Set(["manifestPath", "args", "kind", "targets", "locked"]);
|
|
240
|
+
const CONSUMER_KINDS = new Set(["check"]);
|
|
241
|
+
const CONSUMER_MANIFEST = /^ci\/consumers\/(?:[A-Za-z0-9._-]+\/)+Cargo\.toml$/;
|
|
242
|
+
const CONSUMER_TARGET = /^[A-Za-z0-9_]+(?:-[A-Za-z0-9_.]+){2,3}$/;
|
|
243
|
+
// The row owns manifest, lock, target and output format; arguments may only choose features and target kinds.
|
|
244
|
+
// An ALLOWLIST (0.4.1 review A1): any token not listed is refused, so --help/-h/--version (a "passing" no-op),
|
|
245
|
+
// --target-dir, -C/--directory, --color, --timings, -Z and every other Cargo option stay out of a row.
|
|
246
|
+
// Keep in step with scripts/ci/gate.mjs.
|
|
247
|
+
const CONSUMER_ARG_FLAGS = new Set(["--no-default-features", "--all-features", "--lib", "--bins", "--tests", "--examples", "--benches", "--all-targets"]);
|
|
248
|
+
const CONSUMER_FEATURE_FLAGS = new Set(["--features", "-F"]);
|
|
249
|
+
const CONSUMER_FEATURE_LIST = /^[A-Za-z0-9_][A-Za-z0-9_.+/?:,-]*$/;
|
|
250
|
+
const CONSUMER_ARGS_ALLOWED = `--features <list> (or --features=<list>, -F <list>), ${[...CONSUMER_ARG_FLAGS].join(", ")}`;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Validate the `consumerRows` of one crate's ci/gate.config.json entry and return them normalised. A consumer row
|
|
254
|
+
* checks the owning crate through a SEPARATE Cargo workspace (its own Cargo.lock), run as
|
|
255
|
+
* `cargo check --manifest-path <manifestPath> --locked [--target <t>] <args>`. Unknown fields are refused.
|
|
256
|
+
*/
|
|
257
|
+
export function consumerRows(unit, entry = {}) {
|
|
258
|
+
const rows = entry.consumerRows;
|
|
259
|
+
if (rows === undefined) return [];
|
|
260
|
+
const where = (index) => `${unit} consumerRows[${index}]`;
|
|
261
|
+
if (!Array.isArray(rows)) refuse(`${unit}: consumerRows must be an array`);
|
|
262
|
+
const seen = new Set();
|
|
263
|
+
return rows.map((row, index) => {
|
|
264
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) refuse(`${where(index)} must be an object`);
|
|
265
|
+
for (const key of Object.keys(row)) if (!CONSUMER_ROW_KEYS.has(key)) refuse(`${where(index)}: unknown field ${JSON.stringify(key)} (allowed: ${[...CONSUMER_ROW_KEYS].join(", ")})`);
|
|
266
|
+
const manifestPath = row.manifestPath;
|
|
267
|
+
if (typeof manifestPath !== "string" || !CONSUMER_MANIFEST.test(manifestPath) || manifestPath.split("/").some((part) => part === "." || part === "..")) {
|
|
268
|
+
refuse(`${where(index)}: manifestPath must be a relative ci/consumers/<name>/Cargo.toml path with forward slashes, got ${JSON.stringify(manifestPath)}`);
|
|
269
|
+
}
|
|
270
|
+
const kind = row.kind ?? "check";
|
|
271
|
+
if (!CONSUMER_KINDS.has(kind)) refuse(`${where(index)}: kind must be one of ${[...CONSUMER_KINDS].join(", ")}, got ${JSON.stringify(kind)}`);
|
|
272
|
+
if (row.locked !== undefined && row.locked !== true) refuse(`${where(index)}: locked must be true; a consumer row never runs without its lock file`);
|
|
273
|
+
const args = row.args ?? [];
|
|
274
|
+
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg === "")) refuse(`${where(index)}: args must be an array of non-empty strings`);
|
|
275
|
+
for (let position = 0; position < args.length; position += 1) {
|
|
276
|
+
const arg = args[position];
|
|
277
|
+
if (CONSUMER_ARG_FLAGS.has(arg)) continue;
|
|
278
|
+
if (CONSUMER_FEATURE_FLAGS.has(arg)) {
|
|
279
|
+
const list = args[position + 1];
|
|
280
|
+
if (typeof list !== "string" || !CONSUMER_FEATURE_LIST.test(list)) refuse(`${where(index)}: ${arg} needs a feature list (names separated by commas, not starting with "-"), got ${JSON.stringify(list)}`);
|
|
281
|
+
position += 1;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (arg.startsWith("--features=") && CONSUMER_FEATURE_LIST.test(arg.slice("--features=".length))) continue;
|
|
285
|
+
refuse(`${where(index)}: args may not contain ${JSON.stringify(arg)}; the row sets manifest path, lock, target and output itself, and allows only ${CONSUMER_ARGS_ALLOWED}`);
|
|
286
|
+
}
|
|
287
|
+
const targets = row.targets ?? [];
|
|
288
|
+
if (!Array.isArray(targets) || targets.some((target) => typeof target !== "string" || !CONSUMER_TARGET.test(target))) refuse(`${where(index)}: targets must be an array of target triples`);
|
|
289
|
+
if (new Set(targets).size !== targets.length) refuse(`${where(index)}: targets lists a triple twice`);
|
|
290
|
+
const key = JSON.stringify([manifestPath, args, targets]);
|
|
291
|
+
if (seen.has(key)) refuse(`${where(index)} repeats an earlier row`);
|
|
292
|
+
seen.add(key);
|
|
293
|
+
return { manifestPath, kind, args: [...args], targets: [...targets], locked: true };
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** One Cargo step per target (or one host step when no target is named) for one consumer row. */
|
|
298
|
+
export function consumerRowInvocations(row) {
|
|
299
|
+
const targets = row.targets.length ? row.targets : [null];
|
|
300
|
+
return targets.map((target) => ({ command: "cargo", args: [row.kind, "--manifest-path", row.manifestPath, "--locked", ...(target ? ["--target", target] : []), ...row.args], test: false }));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Refuse a consumer row whose manifest or lock is missing from the workspace, is not tracked by git, or resolves
|
|
305
|
+
* (through a symlink) to anywhere but its own path inside the workspace (fail closed before any command;
|
|
306
|
+
* 0.4.1 review A5). The clean-tree check covers only the files git sees under the consumer directory, so a
|
|
307
|
+
* symlinked or ignored file would otherwise be a checked file nobody reviewed.
|
|
308
|
+
*/
|
|
309
|
+
function assertConsumerFiles(workspaceRoot, unit, rows, git = defaultGit) {
|
|
310
|
+
let realRoot;
|
|
311
|
+
try { realRoot = fs.realpathSync(workspaceRoot); } catch { refuse(`${unit}: cannot resolve the workspace ${workspaceRoot}`); }
|
|
312
|
+
for (const row of rows) {
|
|
313
|
+
for (const file of [row.manifestPath, row.manifestPath.replace(/Cargo\.toml$/, "Cargo.lock")]) {
|
|
314
|
+
const absolute = path.join(workspaceRoot, file);
|
|
315
|
+
if (!fs.existsSync(absolute)) refuse(`${unit}: consumer row needs ${file}, which is not in the workspace`);
|
|
316
|
+
let real;
|
|
317
|
+
try { real = fs.realpathSync(absolute); } catch { refuse(`${unit}: consumer row file ${file} cannot be resolved`); }
|
|
318
|
+
if (path.relative(realRoot, real) !== path.normalize(file)) refuse(`${unit}: consumer row file ${file} resolves outside its own path in the workspace (${real}); a symlink is not allowed`);
|
|
319
|
+
if (git(["ls-files", "--error-unmatch", "--", file], workspaceRoot) === null) refuse(`${unit}: consumer row file ${file} is not tracked by git; commit it first`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The directories whose cleanliness a set of consumer rows depends on. */
|
|
325
|
+
const consumerDirs = (rows) => [...new Set(rows.map((row) => path.posix.dirname(row.manifestPath)))];
|
|
326
|
+
|
|
327
|
+
// The files that decide WHICH steps a publish runs: an uncommitted edit would change the plan the evidence is bound to
|
|
328
|
+
// (0.4.1 review A3). Chosen over reading the HEAD blob so the publisher, the replay and the batch apply one rule:
|
|
329
|
+
// the file on disk must be the committed file, or the publish is refused.
|
|
330
|
+
export const GATE_CONFIG_FILES = Object.freeze(["ci/gate.config.json", "ci/asset-gated.json"]);
|
|
331
|
+
|
|
215
332
|
/** The CI gate's commands for one unit (scripts/ci/gate.mjs npmGate/rustGate), mirrored exactly. */
|
|
216
|
-
export function localTestPlan({ kind, unit, dir, config, manifest, batch = false }) {
|
|
333
|
+
export function localTestPlan({ kind, unit, dir, config, manifest, batch = false, platform = "darwin" }) {
|
|
217
334
|
if (kind === "npm") {
|
|
218
335
|
if (!manifest?.scripts?.test) refuse(`${unit} has no test script; --local never publishes an untested package`);
|
|
219
336
|
return {
|
|
@@ -225,17 +342,24 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
|
|
|
225
342
|
};
|
|
226
343
|
}
|
|
227
344
|
const entry = config?.rust?.[unit] ?? {};
|
|
228
|
-
|
|
345
|
+
// gate.config.json names hosts "macos" and "windows" (scripts/ci/gate.mjs HOST).
|
|
346
|
+
const gateHost = platform === "win32" ? "windows" : "macos";
|
|
347
|
+
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"}`);
|
|
229
348
|
const featureSets = entry.featureSets ?? [[]];
|
|
349
|
+
// Stream Cargo artifact records to the broker; libtest summaries remain plain text for ResultScanner.
|
|
230
350
|
const steps = [];
|
|
231
351
|
for (const features of featureSets) {
|
|
232
|
-
steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--show-output"], test: true });
|
|
233
|
-
if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
|
|
352
|
+
steps.push({ command: "cargo", args: ["test", "-p", unit, "--message-format=json-render-diagnostics", ...features, "--no-fail-fast", "--", "--show-output"], test: true });
|
|
353
|
+
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 });
|
|
234
354
|
}
|
|
235
355
|
// Batch mode lints the whole batch once (batchClippyInvocations) and records it as a batch receipt.
|
|
236
|
-
|
|
237
|
-
for (const features of
|
|
238
|
-
|
|
356
|
+
// Every tested feature set is linted (-D warnings), not only the first.
|
|
357
|
+
if (!batch) for (const features of featureSets) steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"], test: false });
|
|
358
|
+
for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--message-format=json-render-diagnostics", "--all-targets", ...features], test: false });
|
|
359
|
+
// Consumer rows (separate Cargo workspaces) gate only this crate: they come from this crate's own entry.
|
|
360
|
+
const consumers = consumerRows(unit, entry);
|
|
361
|
+
for (const row of consumers) steps.push(...consumerRowInvocations(row));
|
|
362
|
+
return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), consumerRows: consumers, steps };
|
|
239
363
|
}
|
|
240
364
|
|
|
241
365
|
/**
|
|
@@ -247,18 +371,23 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
|
|
|
247
371
|
* unify extra features into a selected crate and each is linted with exactly the features it was before.
|
|
248
372
|
* Crates with a different RIGHTKIT_REQUIRE_ASSETS (asset-gated) stay apart so the environment is unchanged.
|
|
249
373
|
*/
|
|
250
|
-
export function batchClippyInvocations(layer, { featuresOf = () => [], assetGatedOf = () => false } = {}) {
|
|
374
|
+
export function batchClippyInvocations(layer, { featuresOf = () => [], featureSetsOf = (unit) => [featuresOf(unit)], assetGatedOf = () => false } = {}) {
|
|
251
375
|
const groups = new Map();
|
|
252
376
|
for (const unit of layer) {
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
377
|
+
// Every tested feature set of a crate is linted, in order; identical sets of one crate collapse to one run.
|
|
378
|
+
const sets = featureSetsOf(unit) ?? [[]];
|
|
379
|
+
const unique = sets.filter((set, index) => sets.findIndex((other) => JSON.stringify(other ?? []) === JSON.stringify(set ?? [])) === index);
|
|
380
|
+
for (const set of unique) {
|
|
381
|
+
const features = set ?? [];
|
|
382
|
+
const shared = features.length === 0 || (features.length === 1 && features[0] === "--all-features");
|
|
383
|
+
const key = shared ? JSON.stringify([features, assetGatedOf(unit)]) : `unit:${unit}:${JSON.stringify(features)}`;
|
|
384
|
+
if (!groups.has(key)) groups.set(key, { crates: [], features, assetGated: assetGatedOf(unit) });
|
|
385
|
+
groups.get(key).crates.push(unit);
|
|
386
|
+
}
|
|
258
387
|
}
|
|
259
388
|
return [...groups.values()].map(({ crates, features, assetGated }) => ({
|
|
260
389
|
crates, assetGated,
|
|
261
|
-
args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
|
|
390
|
+
args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
|
|
262
391
|
}));
|
|
263
392
|
}
|
|
264
393
|
|
|
@@ -273,10 +402,12 @@ function testEnv(deps, isAssetGated) {
|
|
|
273
402
|
return { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
|
|
274
403
|
}
|
|
275
404
|
|
|
276
|
-
function assertLocalHost(deps, workspaceRoot) {
|
|
405
|
+
function assertLocalHost(deps, workspaceRoot, kind) {
|
|
277
406
|
const platform = deps.platform ?? process.platform;
|
|
278
407
|
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
|
|
408
|
+
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}`);
|
|
409
|
+
// The npm path reads the registry token from the macOS Keychain and ships a Developer-ID-signed helper: Mac only.
|
|
410
|
+
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}`);
|
|
280
411
|
return platform;
|
|
281
412
|
}
|
|
282
413
|
|
|
@@ -300,7 +431,8 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
300
431
|
const run = deps.run ?? (batch ? defaultRunAsync : defaultRun);
|
|
301
432
|
|
|
302
433
|
// 1. Preconditions, fail closed.
|
|
303
|
-
const platform = assertLocalHost(deps, workspaceRoot);
|
|
434
|
+
const platform = assertLocalHost(deps, workspaceRoot, kind);
|
|
435
|
+
if (parsed.qualifyOnly && fs.existsSync(parsed.bundleDir)) refuse(`qualification bundle already exists: ${parsed.bundleDir}`);
|
|
304
436
|
const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
|
|
305
437
|
const unitPath = path.join(workspaceRoot, dir);
|
|
306
438
|
if (!fs.existsSync(unitPath)) refuse(`unit directory not found: ${unitPath}`);
|
|
@@ -324,18 +456,30 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
324
456
|
const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
|
|
325
457
|
if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
|
|
326
458
|
}
|
|
327
|
-
const dirty = git(["status", "--porcelain", "--", dir], workspaceRoot);
|
|
459
|
+
const dirty = git(["status", "--porcelain", "--", dir, ...GATE_CONFIG_FILES, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
|
|
328
460
|
if (dirty === null) refuse(`cannot read git status for ${dir}`);
|
|
329
|
-
if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
|
|
461
|
+
if (dirty !== "") refuse(`tree is not clean for ${dir} or the gate config (${GATE_CONFIG_FILES.join(", ")}); commit first:\n${dirty}`);
|
|
330
462
|
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`);
|
|
331
|
-
log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant,
|
|
463
|
+
log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
|
|
332
464
|
|
|
333
465
|
// 2. The unit's own tests, exactly as the CI gate defines them.
|
|
334
466
|
const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
|
|
335
467
|
const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
|
|
336
468
|
const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
|
|
337
469
|
const isAssetGated = Object.hasOwn(gatedSection, unit);
|
|
338
|
-
const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch) });
|
|
470
|
+
const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch), platform });
|
|
471
|
+
if (plan.consumerRows?.length) {
|
|
472
|
+
// The consumer workspaces are part of the qualified commit: present, and unmodified like the unit itself.
|
|
473
|
+
assertConsumerFiles(workspaceRoot, unit, plan.consumerRows, git);
|
|
474
|
+
const consumerDirty = git(["status", "--porcelain", "--", ...consumerDirs(plan.consumerRows)], workspaceRoot);
|
|
475
|
+
if (consumerDirty === null) refuse(`cannot read git status for the consumer rows of ${unit}`);
|
|
476
|
+
if (consumerDirty !== "") refuse(`tree is not clean for the consumer rows of ${unit}; commit first:\n${consumerDirty}`);
|
|
477
|
+
}
|
|
478
|
+
if (parsed.qualifyOnly) {
|
|
479
|
+
const violations = (deps.scanSecrets ?? scanCrateForSecrets)(unitPath);
|
|
480
|
+
if (violations.length) refuse(`qualification secret scan rejected: ${violations.join(", ")}`);
|
|
481
|
+
plan.steps.unshift({ command: "cargo", args: ["fmt", "-p", unit, "--check"], test: false });
|
|
482
|
+
}
|
|
339
483
|
const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
|
|
340
484
|
const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
|
|
341
485
|
const env = testEnv(deps, isAssetGated);
|
|
@@ -343,15 +487,15 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
343
487
|
// written reason in ci/gate.config.json (rust.<crate>.allowIgnored). The count stays in the evidence.
|
|
344
488
|
const configReason = kind === "cargo" && typeof gateConfig?.rust?.[unit]?.allowIgnored === "string" ? gateConfig.rust[unit].allowIgnored.trim() : "";
|
|
345
489
|
const ignoredAllowed = Boolean(allowIgnored || parsed.allowIgnoredUnits?.includes(unit) || configReason);
|
|
346
|
-
const steps = [];
|
|
490
|
+
const steps = parsed.qualifyOnly ? [{ command: `scanCrateForSecrets ${dir}`, ok: true }] : [];
|
|
347
491
|
for (const step of plan.steps) {
|
|
348
492
|
const commandText = `${step.command} ${step.args.join(" ")}`;
|
|
349
493
|
log(`[right-release] ${plan.id}: ${commandText}`);
|
|
350
|
-
const result = await run(step.command, step.args, { cwd: workspaceRoot, env });
|
|
494
|
+
const result = await run(step.command, step.command === "cargo" ? withCargoJobs(step.args, parsed.jobs) : step.args, { cwd: workspaceRoot, env });
|
|
351
495
|
const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
|
|
352
496
|
if (result.error || result.status !== 0) {
|
|
353
497
|
steps.push(row);
|
|
354
|
-
refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\n${
|
|
498
|
+
refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\nstdout:\n${result.stdout ?? ""}\nstderr:\n${result.stderr ?? ""}`);
|
|
355
499
|
}
|
|
356
500
|
row.ok = true;
|
|
357
501
|
if (row.isTest) {
|
|
@@ -391,16 +535,23 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
391
535
|
const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
|
|
392
536
|
if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
|
|
393
537
|
} else {
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
538
|
+
// A combined publish verifies once in `cargo publish`; detached qualification verifies here before
|
|
539
|
+
// its evidence leaves this host, so the publishing host can reuse that exact archive's proof.
|
|
540
|
+
const packageArgs = ["package", "--locked", ...(parsed.qualifyOnly ? [] : ["--no-verify"]), "-p", unit];
|
|
541
|
+
packCommands.push(`cargo ${packageArgs.join(" ")}`);
|
|
542
|
+
const packed = await run("cargo", withCargoJobs(packageArgs, parsed.jobs), { cwd: workspaceRoot, env });
|
|
398
543
|
if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
|
|
399
|
-
|
|
400
|
-
const
|
|
544
|
+
if (parsed.qualifyOnly) steps.push({ command: `cargo ${packageArgs.join(" ")}`, ok: true });
|
|
545
|
+
const metadataArgs = ["metadata", "--offline", "--format-version", "1", "--no-deps"];
|
|
546
|
+
const meta = await runCargoReadOnly(metadataArgs, { cwd: workspaceRoot, env }, (_command, args, settings) => run("cargo", args, settings),
|
|
547
|
+
(failed) => log(`[right-release] offline Cargo metadata missed cache; retrying online once\n${failed.stdout ?? ""}${failed.stderr ? `\n${failed.stderr}` : ""}`));
|
|
548
|
+
const actualMetadataArgs = meta.usedOfflineFallback ? metadataArgs.filter((arg) => arg !== "--offline") : metadataArgs;
|
|
549
|
+
packCommands.push(`cargo ${actualMetadataArgs.join(" ")}`);
|
|
401
550
|
let targetDir;
|
|
402
551
|
try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
|
|
403
|
-
if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir))
|
|
552
|
+
if (meta.error || meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) {
|
|
553
|
+
refuse(`cannot read the Cargo target directory\n${tail(`${meta.error?.message ?? ""}\n${meta.stdout ?? ""}\n${meta.stderr ?? ""}`)}`);
|
|
554
|
+
}
|
|
404
555
|
artifactFile = path.join(targetDir, "package", `${unit}-${version}.crate`);
|
|
405
556
|
if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
|
|
406
557
|
}
|
|
@@ -411,13 +562,14 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
411
562
|
const timestamp = (deps.now ?? (() => new Date().toISOString()))();
|
|
412
563
|
const evidence = {
|
|
413
564
|
schema: GATE_SCHEMA, kind, name: unit, version, commit: head, artifactSha256,
|
|
414
|
-
hostPolicy:
|
|
565
|
+
hostPolicy: LOCAL_HOST_POLICIES[platform], hostPolicyReason: localHostPolicyReason(platform),
|
|
415
566
|
runs: [{
|
|
416
|
-
host: { os:
|
|
567
|
+
host: { os: platform, arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
|
|
417
568
|
command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
|
|
418
569
|
passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored: ignoredAllowed,
|
|
419
570
|
...(configReason ? { allowIgnoredReason: configReason } : {}),
|
|
420
571
|
requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
|
|
572
|
+
...(parsed.jobs === undefined ? {} : { jobs: parsed.jobs }),
|
|
421
573
|
...(batch ? { batch: structuredClone(batch.receipt) } : {}),
|
|
422
574
|
steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
|
|
423
575
|
}],
|
|
@@ -429,16 +581,28 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
429
581
|
const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath };
|
|
430
582
|
(deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
|
|
431
583
|
|
|
584
|
+
if (parsed.qualifyOnly) {
|
|
585
|
+
// Keep bytes and their normal gate evidence together outside the checkout. Never overwrite a bundle.
|
|
586
|
+
fs.mkdirSync(parsed.bundleDir, { recursive: false });
|
|
587
|
+
const bundledArtifact = path.join(parsed.bundleDir, path.basename(artifactFile));
|
|
588
|
+
const bundledEvidence = path.join(parsed.bundleDir, "gate.json");
|
|
589
|
+
fs.copyFileSync(artifactFile, bundledArtifact, fs.constants.COPYFILE_EXCL);
|
|
590
|
+
if (sha256(fs.readFileSync(bundledArtifact)) !== artifactSha256) refuse("qualification archive changed while copying");
|
|
591
|
+
fs.copyFileSync(evidencePath, bundledEvidence, fs.constants.COPYFILE_EXCL);
|
|
592
|
+
log(`[right-release] qualified ${unit}@${version}; bundle ${parsed.bundleDir}; publish cargo --crate ${unit} --from-evidence ${bundledEvidence}`);
|
|
593
|
+
return { kind, name: unit, version, commit: head, artifactSha256, evidencePath: bundledEvidence, artifactFile: bundledArtifact, qualifiedOnly: true };
|
|
594
|
+
}
|
|
595
|
+
|
|
432
596
|
// 5. The EXISTING publish path.
|
|
433
597
|
if (kind === "npm") {
|
|
434
|
-
(deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
|
|
598
|
+
(deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath, workspaceRoot }, deps.publishDeps ?? {});
|
|
435
599
|
} else {
|
|
436
600
|
// trustEvidenceSteps: the evidence just written (and validated by the gate above) proves this commit's tests
|
|
437
601
|
// and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
|
|
438
602
|
// publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
|
|
439
603
|
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 }));
|
|
604
|
+
const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath, trustEvidenceSteps: true, verifyOnPublish: true, offlineReadOnly: true }, execute, deps.cargoDeps ?? {});
|
|
605
|
+
await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }, workspaceFeedOptions(workspaceRoot)));
|
|
442
606
|
}
|
|
443
607
|
log(`[right-release] published ${unit}@${version}`);
|
|
444
608
|
return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
|
|
@@ -460,7 +624,7 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
460
624
|
const git = deps.git ?? defaultGit;
|
|
461
625
|
const readJson = deps.readJson ?? defaultReadJson;
|
|
462
626
|
const run = deps.run ?? defaultRunAsync;
|
|
463
|
-
assertLocalHost(deps, workspaceRoot);
|
|
627
|
+
const platform = assertLocalHost(deps, workspaceRoot, "cargo");
|
|
464
628
|
|
|
465
629
|
// 1. The crate list and its dependency layers.
|
|
466
630
|
const requested = parsed.batch.file ? parseCrateLayers((deps.readText ?? ((file) => fs.readFileSync(file, "utf8")))(parsed.batch.file)) : [parsed.batch.crates];
|
|
@@ -476,6 +640,7 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
476
640
|
const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
|
|
477
641
|
const isAssetGated = (unit) => Object.hasOwn(assetGated.crates ?? {}, unit);
|
|
478
642
|
const versions = new Map();
|
|
643
|
+
const batchConsumerDirs = new Set();
|
|
479
644
|
for (const unit of crates) {
|
|
480
645
|
const dir = `crates/${unit}`;
|
|
481
646
|
if (!fs.existsSync(path.join(workspaceRoot, dir))) refuse(`unit directory not found: ${path.join(workspaceRoot, dir)}`);
|
|
@@ -483,20 +648,24 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
483
648
|
if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
|
|
484
649
|
if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
|
|
485
650
|
versions.set(unit, pkg.version);
|
|
486
|
-
|
|
651
|
+
// Refuses an OS-restricted crate, a malformed consumer row, or an untracked or symlinked consumer file now, not mid-batch.
|
|
652
|
+
const rows = localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true, platform }).consumerRows ?? [];
|
|
653
|
+
assertConsumerFiles(workspaceRoot, unit, rows, git);
|
|
654
|
+
for (const consumerDir of consumerDirs(rows)) batchConsumerDirs.add(consumerDir);
|
|
487
655
|
}
|
|
488
656
|
const head = git(["rev-parse", "HEAD"], workspaceRoot);
|
|
489
657
|
if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
|
|
490
658
|
const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
|
|
491
659
|
if (!tree || !/^[0-9a-f]{40}$/.test(tree)) refuse("cannot resolve the HEAD tree");
|
|
492
|
-
// Every batch crate
|
|
493
|
-
|
|
660
|
+
// Every batch crate, the workspace manifest and lock, the gate config and EVERY crate's consumer directories must
|
|
661
|
+
// match the commit the batch checks are bound to, before the first layer publishes (0.4.1 review A3, A4).
|
|
662
|
+
const dirty = git(["status", "--porcelain", "--", ...crates.map((unit) => `crates/${unit}`), "Cargo.toml", "Cargo.lock", ...GATE_CONFIG_FILES, ...batchConsumerDirs], workspaceRoot);
|
|
494
663
|
if (dirty === null) refuse("cannot read git status for the batch");
|
|
495
|
-
if (dirty !== "") refuse(`tree is not clean for the batch crates
|
|
664
|
+
if (dirty !== "") refuse(`tree is not clean for the batch crates, the workspace manifest, the gate config or a consumer directory; commit first:\n${dirty}`);
|
|
496
665
|
const registryHas = deps.registryHas ?? defaultRegistryHas;
|
|
497
666
|
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
667
|
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,
|
|
668
|
+
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)`);
|
|
500
669
|
|
|
501
670
|
// 3. Batch-level checks, once, at the batch commit. Each is recorded in every crate's evidence.
|
|
502
671
|
const env = testEnv(deps, false);
|
|
@@ -518,9 +687,9 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
518
687
|
const scanText = `scanCrateForSecrets ${crates.map((unit) => `crates/${unit}`).join(" ")}`;
|
|
519
688
|
checks.push({ name: "secret-scan", command: scanText, ok: true, ...stamp, crates });
|
|
520
689
|
commands.push(scanText);
|
|
521
|
-
const
|
|
690
|
+
const featureSetsOf = (unit) => gateConfig?.rust?.[unit]?.featureSets ?? [[]];
|
|
522
691
|
for (const layer of layers) {
|
|
523
|
-
for (const invocation of batchClippyInvocations(layer, {
|
|
692
|
+
for (const invocation of batchClippyInvocations(layer, { featureSetsOf, assetGatedOf: isAssetGated })) {
|
|
524
693
|
await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated));
|
|
525
694
|
}
|
|
526
695
|
}
|
package/managed-cargo.mjs
CHANGED
|
@@ -18,6 +18,14 @@ export function managedRuntimeClient(env = process.env, platform = process.platf
|
|
|
18
18
|
const client = path.posix.join(env.HOME || homedir(), 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
|
|
19
19
|
if (exists(client)) return client;
|
|
20
20
|
}
|
|
21
|
+
// RustRight's own install on Windows (2026-10-18 cutover): the runtime moved out of the control root to
|
|
22
|
+
// <drive>\.rustright\runtime, beside the control root's drive, and runtime-current was retired.
|
|
23
|
+
if (platform === 'win32') {
|
|
24
|
+
for (const root of roots) {
|
|
25
|
+
const client = path.win32.join(path.win32.parse(root).root, '.rustright', 'runtime', 'rustright.exe');
|
|
26
|
+
if (exists(client)) return client;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
21
29
|
return undefined;
|
|
22
30
|
}
|
|
23
31
|
|
package/mirror-root-artifact.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// `{ file, name?, pruneSuffix? }`. `pruneSuffix` (for example "-setup.exe") then removes the other files with that suffix
|
|
7
7
|
// from the managed directory, so it holds exactly the current artifact. Run it as `targets.<platform>.postSign`.
|
|
8
8
|
// Output goes only to RIGHT_RELEASE_ARTIFACT_DIR (outside the repository); it never writes into the repository root.
|
|
9
|
+
import { configuredManagedRootSpellings } from "./artifact-storage.mjs";
|
|
9
10
|
import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
|
|
10
11
|
import { execFileSync } from "./background-process.mjs";
|
|
11
12
|
import path from "node:path";
|
|
@@ -113,11 +114,14 @@ function atomicCopy(from, to) {
|
|
|
113
114
|
function assertSafePath(target) {
|
|
114
115
|
const absolute = path.resolve(target);
|
|
115
116
|
const root = path.parse(absolute).root;
|
|
117
|
+
// Only the configured managed root itself may be a symlink (same rule as artifact-storage assertSafeMutation).
|
|
118
|
+
const managedRoots = configuredManagedRootSpellings();
|
|
119
|
+
const configured = (candidate) => managedRoots.has(process.platform === "win32" ? path.resolve(candidate).toLowerCase() : path.resolve(candidate));
|
|
116
120
|
let cursor = root;
|
|
117
121
|
for (const segment of path.relative(root, absolute).split(path.sep).filter(Boolean)) {
|
|
118
122
|
cursor = path.join(cursor, segment);
|
|
119
123
|
try {
|
|
120
|
-
if (lstatSync(cursor).isSymbolicLink()) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
|
|
124
|
+
if (lstatSync(cursor).isSymbolicLink() && !configured(cursor)) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
|
|
121
125
|
} catch (error) {
|
|
122
126
|
if (error.code !== "ENOENT") throw error;
|
|
123
127
|
}
|
package/native-cargo-layout.mjs
CHANGED
|
@@ -18,6 +18,8 @@ export function resolveNativeCargoLayout({
|
|
|
18
18
|
read = (file) => readFileSync(file, "utf8"),
|
|
19
19
|
requireManifest = false,
|
|
20
20
|
requireLock = false,
|
|
21
|
+
// The repository the app lives in. A manifest outside the app searches for its workspace only up to this directory.
|
|
22
|
+
repoRoot,
|
|
21
23
|
} = {}) {
|
|
22
24
|
if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
|
|
23
25
|
const resolvedAppRoot = path.resolve(appRoot);
|
|
@@ -31,7 +33,7 @@ export function resolveNativeCargoLayout({
|
|
|
31
33
|
const explicitWorkspace = nativeAssembly?.workspaceRoot ?? nativeAssembly?.cargoWorkspaceRoot;
|
|
32
34
|
const workspaceRoot = explicitWorkspace
|
|
33
35
|
? normalizeWorkspaceRoot(resolveDeclaredPath(resolvedAppRoot, explicitWorkspace), exists)
|
|
34
|
-
: discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read });
|
|
36
|
+
: discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined });
|
|
35
37
|
|
|
36
38
|
const lock = resolveCargoLock({
|
|
37
39
|
appRoot: resolvedAppRoot,
|
|
@@ -84,6 +86,28 @@ export function resolveNativeCargoLayout({
|
|
|
84
86
|
return layout;
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* True when the app has a native Cargo manifest the release must hash and build against: the config declares one
|
|
91
|
+
* (nativeAssembly.cargoManifest / manifest) or the historical src-tauri/Cargo.toml exists. A native iOS app (xcodegen
|
|
92
|
+
* project, Rust static library built by iosPackage.prepare) has neither, and then owns no Cargo layout at all.
|
|
93
|
+
*/
|
|
94
|
+
export function nativeCargoDeclared({ appRoot, config, nativeAssembly = config?.nativeAssembly, exists = existsSync } = {}) {
|
|
95
|
+
if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
|
|
96
|
+
if (nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest) return true;
|
|
97
|
+
return exists(path.resolve(appRoot, DEFAULT_MANIFEST));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The manifest nativeAssembly names, if any (a path relative to the app root, or absolute). */
|
|
101
|
+
export function declaredCargoManifest(config, nativeAssembly = config?.nativeAssembly) {
|
|
102
|
+
return nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True when the historical Tauri host manifest exists. */
|
|
106
|
+
export function hasDefaultTauriManifest({ appRoot, exists = existsSync } = {}) {
|
|
107
|
+
if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
|
|
108
|
+
return exists(path.resolve(appRoot, DEFAULT_MANIFEST));
|
|
109
|
+
}
|
|
110
|
+
|
|
87
111
|
export const resolveCargoLayout = resolveNativeCargoLayout;
|
|
88
112
|
export const resolveNativeCargoManifest = resolveNativeCargoLayout;
|
|
89
113
|
export const resolveCargoManifest = resolveNativeCargoLayout;
|
|
@@ -160,20 +184,29 @@ function resolveCargoLock({ appRoot, manifestPath, manifestDir, workspaceRoot, n
|
|
|
160
184
|
return { path: found, source: found ? orderedSources[candidates.indexOf(found)] : undefined, candidates };
|
|
161
185
|
}
|
|
162
186
|
|
|
163
|
-
function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read }) {
|
|
187
|
+
function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read, repoRoot }) {
|
|
164
188
|
const parsed = parseCargoManifest(manifestText);
|
|
165
189
|
const declaredWorkspace = parsed.workspacePath ?? parsed.packageWorkspace;
|
|
166
190
|
if (declaredWorkspace) {
|
|
167
191
|
return normalizeWorkspaceRoot(resolveDeclaredPath(manifestDir, declaredWorkspace), exists);
|
|
168
192
|
}
|
|
169
193
|
if (parsed.hasWorkspace) return manifestDir;
|
|
194
|
+
// A manifest inside the app searches up to the app root (unchanged); one outside it (a shared crates/ tree) searches up to the
|
|
195
|
+
// nearest directory that contains both, which is where its workspace and Cargo.lock live.
|
|
196
|
+
// Only a caller that names the repository opts in to the climb above the app (native iOS); every other caller keeps the
|
|
197
|
+
// exact historical search, which never leaves the app root.
|
|
198
|
+
let boundary = appRoot;
|
|
199
|
+
if (repoRoot && !isWithin(appRoot, manifestDir)) {
|
|
200
|
+
boundary = commonAncestor(appRoot, manifestDir);
|
|
201
|
+
if (!isWithin(repoRoot, boundary)) boundary = repoRoot;
|
|
202
|
+
}
|
|
170
203
|
let current = manifestDir;
|
|
171
204
|
for (;;) {
|
|
172
205
|
const candidate = path.join(current, "Cargo.toml");
|
|
173
206
|
if (candidate !== manifestPath && exists(candidate) && parseCargoManifest(readIfPresent(candidate, exists, read)).hasWorkspace) return current;
|
|
174
|
-
if (current ===
|
|
207
|
+
if (current === boundary) break;
|
|
175
208
|
const parent = path.dirname(current);
|
|
176
|
-
if (parent === current || !isWithin(
|
|
209
|
+
if (parent === current || !isWithin(boundary, parent)) break;
|
|
177
210
|
current = parent;
|
|
178
211
|
}
|
|
179
212
|
return manifestDir;
|
|
@@ -231,6 +264,16 @@ function readIfPresent(file, exists, read) {
|
|
|
231
264
|
try { return exists(file) ? read(file) : ""; } catch { return ""; }
|
|
232
265
|
}
|
|
233
266
|
|
|
267
|
+
function commonAncestor(left, right) {
|
|
268
|
+
let current = path.resolve(left);
|
|
269
|
+
while (!isWithin(current, path.resolve(right))) {
|
|
270
|
+
const parent = path.dirname(current);
|
|
271
|
+
if (parent === current) return current;
|
|
272
|
+
current = parent;
|
|
273
|
+
}
|
|
274
|
+
return current;
|
|
275
|
+
}
|
|
276
|
+
|
|
234
277
|
function isWithin(root, candidate) {
|
|
235
278
|
const relative = path.relative(root, candidate);
|
|
236
279
|
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|