@rightkit/release 0.4.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/local-publish.mjs CHANGED
@@ -25,7 +25,7 @@ import { GATE_SCHEMA, MAX_CARGO_JOBS, defaultEvidencePath, enforcePublicationGat
25
25
  import { runCargoPublish, runCargoReadOnly, scanCrateForSecrets } from "./publish-cargo.mjs";
26
26
  import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
27
27
  import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
28
- import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
28
+ import { recordPublished, stageSourceVersions, workspaceFeedOptions } from "./rightkit-sync.mjs";
29
29
  import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
30
30
 
31
31
  export const LOCAL_OPERATOR = "agent (local-build grant)";
@@ -236,6 +236,99 @@ export function allFeatureCheckSets(entry = {}) {
236
236
  return entry.checkFeatureSets ?? [["--all-features"]];
237
237
  }
238
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
+
239
332
  /** The CI gate's commands for one unit (scripts/ci/gate.mjs npmGate/rustGate), mirrored exactly. */
240
333
  export function localTestPlan({ kind, unit, dir, config, manifest, batch = false, platform = "darwin" }) {
241
334
  if (kind === "npm") {
@@ -260,9 +353,13 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
260
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 });
261
354
  }
262
355
  // 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 });
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 });
264
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 });
265
- return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
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 };
266
363
  }
267
364
 
268
365
  /**
@@ -274,14 +371,19 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
274
371
  * unify extra features into a selected crate and each is linted with exactly the features it was before.
275
372
  * Crates with a different RIGHTKIT_REQUIRE_ASSETS (asset-gated) stay apart so the environment is unchanged.
276
373
  */
277
- export function batchClippyInvocations(layer, { featuresOf = () => [], assetGatedOf = () => false } = {}) {
374
+ export function batchClippyInvocations(layer, { featuresOf = () => [], featureSetsOf = (unit) => [featuresOf(unit)], assetGatedOf = () => false } = {}) {
278
375
  const groups = new Map();
279
376
  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);
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
+ }
285
387
  }
286
388
  return [...groups.values()].map(({ crates, features, assetGated }) => ({
287
389
  crates, assetGated,
@@ -354,9 +456,9 @@ async function publishUnit(parsed, deps, batch) {
354
456
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
355
457
  if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
356
458
  }
357
- const dirty = git(["status", "--porcelain", "--", dir, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
459
+ const dirty = git(["status", "--porcelain", "--", dir, ...GATE_CONFIG_FILES, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
358
460
  if (dirty === null) refuse(`cannot read git status for ${dir}`);
359
- 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}`);
360
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`);
361
463
  log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
362
464
 
@@ -366,6 +468,13 @@ async function publishUnit(parsed, deps, batch) {
366
468
  const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
367
469
  const isAssetGated = Object.hasOwn(gatedSection, unit);
368
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
+ }
369
478
  if (parsed.qualifyOnly) {
370
479
  const violations = (deps.scanSecrets ?? scanCrateForSecrets)(unitPath);
371
480
  if (violations.length) refuse(`qualification secret scan rejected: ${violations.join(", ")}`);
@@ -486,14 +595,14 @@ async function publishUnit(parsed, deps, batch) {
486
595
 
487
596
  // 5. The EXISTING publish path.
488
597
  if (kind === "npm") {
489
- (deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
598
+ (deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath, workspaceRoot }, deps.publishDeps ?? {});
490
599
  } else {
491
600
  // trustEvidenceSteps: the evidence just written (and validated by the gate above) proves this commit's tests
492
601
  // and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
493
602
  // publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
494
603
  const execute = deps.cargoExecute ?? (batch ? batchCargoExecute(unit) : undefined);
495
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 ?? {});
496
- await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }));
605
+ await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }, workspaceFeedOptions(workspaceRoot)));
497
606
  }
498
607
  log(`[right-release] published ${unit}@${version}`);
499
608
  return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
@@ -531,6 +640,7 @@ async function runLocalPublishBatch(parsed, deps) {
531
640
  const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
532
641
  const isAssetGated = (unit) => Object.hasOwn(assetGated.crates ?? {}, unit);
533
642
  const versions = new Map();
643
+ const batchConsumerDirs = new Set();
534
644
  for (const unit of crates) {
535
645
  const dir = `crates/${unit}`;
536
646
  if (!fs.existsSync(path.join(workspaceRoot, dir))) refuse(`unit directory not found: ${path.join(workspaceRoot, dir)}`);
@@ -538,16 +648,20 @@ async function runLocalPublishBatch(parsed, deps) {
538
648
  if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
539
649
  if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
540
650
  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
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);
542
655
  }
543
656
  const head = git(["rev-parse", "HEAD"], workspaceRoot);
544
657
  if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
545
658
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
546
659
  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);
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);
549
663
  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}`);
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}`);
551
665
  const registryHas = deps.registryHas ?? defaultRegistryHas;
552
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);
553
667
  if (published.length) refuse(`already on the crates.io registry: ${published.join(", ")}; bump the version or remove them from the batch`);
@@ -573,9 +687,9 @@ async function runLocalPublishBatch(parsed, deps) {
573
687
  const scanText = `scanCrateForSecrets ${crates.map((unit) => `crates/${unit}`).join(" ")}`;
574
688
  checks.push({ name: "secret-scan", command: scanText, ok: true, ...stamp, crates });
575
689
  commands.push(scanText);
576
- const featuresOf = (unit) => (gateConfig?.rust?.[unit]?.featureSets ?? [[]])[0];
690
+ const featureSetsOf = (unit) => gateConfig?.rust?.[unit]?.featureSets ?? [[]];
577
691
  for (const layer of layers) {
578
- for (const invocation of batchClippyInvocations(layer, { featuresOf, assetGatedOf: isAssetGated })) {
692
+ for (const invocation of batchClippyInvocations(layer, { featureSetsOf, assetGatedOf: isAssetGated })) {
579
693
  await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated));
580
694
  }
581
695
  }
@@ -795,12 +795,14 @@
795
795
  "cargo:serde_json",
796
796
  "cargo:tauri",
797
797
  "cargo:tauri-build",
798
+ "cargo:tauri-plugin-http",
798
799
  "cargo:tauri-plugin-os",
799
800
  "cargo:tauri-plugin-process",
800
801
  "cargo:tauri-plugin-updater",
801
802
  "npm:@tailwindcss/vite",
802
803
  "npm:@tauri-apps/api",
803
804
  "npm:@tauri-apps/cli",
805
+ "npm:@tauri-apps/plugin-http",
804
806
  "npm:@tauri-apps/plugin-os",
805
807
  "npm:@tauri-apps/plugin-process",
806
808
  "npm:@tauri-apps/plugin-updater",