@rightkit/release 0.5.1 → 0.6.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 +13 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/build-release.mjs +62 -2
- package/ownership-approval.mjs +5 -1
- package/ownership-lockfiles.mjs +1 -1
- package/ownership-verdict.mjs +2 -1
- package/package.json +1 -1
- package/release-state.mjs +3 -1
- package/release.mjs +75 -4
- package/rightkit-registry-resolver.mjs +732 -0
- package/rightkit-versions.json +8 -6
package/README.md
CHANGED
|
@@ -486,6 +486,19 @@ No agent may write to the internal drive (C: on Windows, the internal disk on ma
|
|
|
486
486
|
|
|
487
487
|
`outcome` is `allowed` (file created and removed), `blocked` (`EACCES`, `EPERM` or `EROFS`) or `error` (directory missing or another failure; `code` says which). `code` is `null` when allowed, except `UNLINK_<code>` when the probe file could not be removed. `internalWritable` is true when profile, local-temp or os-temp is `allowed`.
|
|
488
488
|
|
|
489
|
+
## RightKit pins are proven against the registries (`rightkit-registry-resolver.mjs`)
|
|
490
|
+
|
|
491
|
+
At build admission the controller resolves the app's EXACT locked RightKit versions (every `Cargo.lock` entry and `pnpm-lock.yaml` resolution of a RightKit-owned package) against crates.io (index `cksum` + `yanked`) and npm (`dist.integrity` + `deprecated`). A new version of a known RightKit package needs no new release of this tool. The claim is scoped to KNOWN names: ownership comes from `rightkit-versions.json` and `rightkit-ownership.json`, a name prefix alone is refused, and there is no maintainer-membership proof, so a package first published after this tool still needs an ownership update. Only canonical registry sources count (no path, git, tarball or directory); the recorded minimum and `revoked` policy still apply; pins are never rewritten.
|
|
492
|
+
|
|
493
|
+
What the digest comparison is: the lock digest and the registry digest agree (metadata agreement). It is NOT proof of the bytes a build consumes; this tool does not materialize or hash the installed packages, so no byte-level claim is made.
|
|
494
|
+
|
|
495
|
+
- **Coverage.** Every RightKit dependency a manifest declares (`Cargo.toml`, `package.json`, including the directory of every canonical lock outside the app root) needs a lock entry for that package name somewhere in the canonical lock inventory; a missing or incomplete lock refuses admission, with no bundled-only fallback. This is a name-level check, not importer-specific lock proof. Lock inputs come from the build's canonical input inventory (`requiredInputs`, `pinnedCargoLocks`, native-iOS locks), not a separately built list.
|
|
496
|
+
- **Freshness is a build-admission gate.** A fresh `right-release build` reads each package's latest live (crates.io `max_stable_version`, npm `dist-tags.latest`) and REFUSES every pin behind it (`pinned X -> latest Y: run right-update run`), held or not: a `.rightkit/update.json` hold is a migration lane task, reported with the refusal, never an exception. If latest cannot be read live the failure is RECOVERABLE (a cached latest is only last-observed). The latest row is always the live registry state. The cache keeps a separate high-water mark as downgrade protection for derived answers only: a lower crates.io answer while the higher version is still published is a recoverable `eligibility-conflict`; a withdrawn high-water version resolves itself; an npm `latest` tag moved lower on purpose is followed.
|
|
497
|
+
- **Reuse is trusted-local, not authenticated.** The admitted snapshot (`<state root>/rightkit-registry-snapshot.json`), the admission record (`rightkit-admission.json`: release id, commit, platform, snapshot digest, hash of `release-inputs.lock.json`) and the lock payload are plain files in the release state root. The release worker (`RIGHT_RELEASE_RIGHTKIT_SNAPSHOT`), `--resume-from`, the seal and `upload` check that they agree with each other, with this release's identity and with the current locks, and refuse a missing, foreign, inconsistent or stale set without resolving again. That catches mix-ups and edits that leave the files inconsistent. It does NOT authenticate them: anyone who can write the state root can forge a consistent set, and a resume proves only that the recorded admission is consistent with this release and the current locks, not that the registry still agrees or that the controller wrote it. There is no signing layer, and no existing broker attestation is reused here. A publication after admission changes nothing and freshness is not re-checked.
|
|
498
|
+
- **Sealed evidence.** The sealed `release-manifest.json` carries `rightReleaseVersion`, `rightkitRegistryEvidence: 1` and `rightkitRegistry` (`digest`, `snapshot`). A manifest without the evidence is refused on every seal and upload path. The only exemption is an explicitly identified pre-0.6.0 release: its manifest records no tool version (or one below 0.6.0) and its release id is named in `RIGHT_RELEASE_LEGACY_REGISTRY_RELEASES`. A stripped seal that records 0.6.0 or later is refused whatever is set.
|
|
499
|
+
- `right-release doctor` resolves live and lists newer versions as informational; `right-release doctor --admission` applies the admission refusals.
|
|
500
|
+
- Cache: `<RIGHT_RELEASE_CACHE_ROOT>/rightkit-registry/latest/` (external storage; none when no cache root is configured). There is no offline override for a fresh admission: a registry outage is a recoverable failure after bounded retries.
|
|
501
|
+
|
|
489
502
|
## Known issues
|
|
490
503
|
|
|
491
504
|
- `smol-toml` is pinned exactly. A consumer that depends on a different `smol-toml` version gets a second copy.
|
package/bin/darwin/rightkit-swap
CHANGED
|
Binary file
|
package/build-release.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { iosOwnershipScope } from "./ownership-scope.mjs";
|
|
22
22
|
import { spawn, spawnSync } from "./background-process.mjs";
|
|
23
|
+
import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord } from "./rightkit-registry-resolver.mjs";
|
|
23
24
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
24
25
|
import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
|
|
25
26
|
import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
@@ -106,6 +107,7 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativ
|
|
|
106
107
|
|
|
107
108
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
108
109
|
const WORKER = path.join(TOOL_ROOT, "release.mjs");
|
|
110
|
+
const RELEASE_TOOL_VERSION = JSON.parse(readFileSync(path.join(TOOL_ROOT, "package.json"), "utf8")).version;
|
|
109
111
|
const args = process.argv.slice(2);
|
|
110
112
|
let configName = "right-release.config.mjs";
|
|
111
113
|
let platform = process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform;
|
|
@@ -365,6 +367,59 @@ try {
|
|
|
365
367
|
env.RIGHT_RELEASE_ARTIFACT_DIR = managedArtifactDir;
|
|
366
368
|
const buildRoot = managedCargoTarget ? null : path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
|
|
367
369
|
env.RIGHT_RELEASE_STATE_ROOT = stateRoot;
|
|
370
|
+
// Resolve the exact locked RightKit pins against the canonical registries ONCE, here (build admission), and keep
|
|
371
|
+
// the snapshot in this release's state root with an admission record naming the release (id, commit, platform) and
|
|
372
|
+
// the snapshot digest. The release worker, a --resume-from phase resume and the sealed manifest consume only that
|
|
373
|
+
// recorded snapshot; a missing one is refused, never re-resolved. These are trusted-local files: a resume proves the
|
|
374
|
+
// recorded admission is consistent with this release and the current locks, not that the registry still agrees nor
|
|
375
|
+
// that the controller wrote it (see rightkit-registry-resolver.mjs, TRUST SCOPE). Lock inputs come from the canonical build-input
|
|
376
|
+
// inventory (requiredInputs, pinnedCargoLocks), not a reconstructed list.
|
|
377
|
+
mkdirSync(stateRoot, { recursive: true });
|
|
378
|
+
const rightKitSnapshotFile = path.join(stateRoot, SNAPSHOT_FILE);
|
|
379
|
+
const admissionIdentity = { releaseId, commit, platform };
|
|
380
|
+
const sealedManifestPath = path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir, "release-manifest.json");
|
|
381
|
+
const sealedExists = existsSync(sealedManifestPath);
|
|
382
|
+
const rightKitReuse = Boolean(resumeFrom) || sealedExists;
|
|
383
|
+
let boundSnapshot = null;
|
|
384
|
+
let legacySealed = false;
|
|
385
|
+
try {
|
|
386
|
+
if (sealedExists) {
|
|
387
|
+
const sealedManifest = JSON.parse(readFileSync(sealedManifestPath, "utf8"));
|
|
388
|
+
legacySealed = assertManifestRegistryEvidence(sealedManifest).legacy;
|
|
389
|
+
boundSnapshot = legacySealed ? null : sealedManifest.rightkitRegistry.snapshot;
|
|
390
|
+
} else if (resumeFrom) {
|
|
391
|
+
boundSnapshot = loadAdmittedSnapshot(stateRoot, { ...admissionIdentity, inPreflight: true });
|
|
392
|
+
}
|
|
393
|
+
} catch (error) {
|
|
394
|
+
fail(`${error instanceof RightKitRegistryError && error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
|
|
395
|
+
}
|
|
396
|
+
let rightKitResolution = null;
|
|
397
|
+
if (!legacySealed) {
|
|
398
|
+
try {
|
|
399
|
+
rightKitResolution = await prepareRightKitResolution({
|
|
400
|
+
root: appRoot,
|
|
401
|
+
extraLocks: rightKitLockInventory({ requiredInputs, cargoLockPaths: pinnedCargoLocks, nativeLayout, config, root: appRoot }),
|
|
402
|
+
versions: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-versions.json"), "utf8")),
|
|
403
|
+
ownership: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-ownership.json"), "utf8")),
|
|
404
|
+
releaseVersion: RELEASE_TOOL_VERSION,
|
|
405
|
+
snapshotFile: rightKitSnapshotFile,
|
|
406
|
+
boundSnapshot,
|
|
407
|
+
reuse: rightKitReuse,
|
|
408
|
+
// Build admission: a fresh build samples registry latest live and refuses EVERY pin behind it (a hold is a lane
|
|
409
|
+
// task, not an exception). The snapshot is then frozen through build, seal, resume and upload.
|
|
410
|
+
admission: !rightKitReuse,
|
|
411
|
+
write: true,
|
|
412
|
+
});
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (!(error instanceof RightKitRegistryError)) throw error;
|
|
415
|
+
fail(`${error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
|
|
416
|
+
}
|
|
417
|
+
for (const line of rightKitResolution.freshness.tasks) console.error(`[right-release] LANE TASK ${line}`);
|
|
418
|
+
for (const line of rightKitResolution.freshness.warnings) console.error(`[right-release] WARNING ${line}`);
|
|
419
|
+
console.error(`[right-release] rightkit-registry: ${rightKitResolution.summary}`);
|
|
420
|
+
if (rightKitReuse) console.error("[right-release] resume reuses the recorded admission snapshot: it is checked for consistency with this release and the current locks only; the registry is not asked again and the record is not authenticated");
|
|
421
|
+
env[SNAPSHOT_ENV] = rightKitSnapshotFile;
|
|
422
|
+
}
|
|
368
423
|
env.RIGHT_RELEASE_VAULT_STATE_ROOT = path.join(vaultRoot, "state");
|
|
369
424
|
const lockPayload = {
|
|
370
425
|
schema: 1,
|
|
@@ -418,6 +473,8 @@ try {
|
|
|
418
473
|
writeJson(path.join(stateRoot, "release-waits.json"), { schema: 1, recordedAt: new Date().toISOString(), ...preBrokerWaits });
|
|
419
474
|
if (buildRoot) writeJson(path.join(buildRoot, "release-inputs.lock.json"), lockPayload);
|
|
420
475
|
writeJson(path.join(stateRoot, "release-inputs.lock.json"), lockPayload);
|
|
476
|
+
// The controller's admission record: which snapshot was admitted for exactly this release and input identity.
|
|
477
|
+
if (rightKitResolution) writeAdmissionRecord(stateRoot, { ...admissionIdentity, snapshot: rightKitResolution.snapshot });
|
|
421
478
|
checkpoint(stateRoot, "preflight_complete");
|
|
422
479
|
},
|
|
423
480
|
prepare: async () => {
|
|
@@ -474,7 +531,7 @@ try {
|
|
|
474
531
|
});
|
|
475
532
|
},
|
|
476
533
|
seal: async ({ sealedDir }) => {
|
|
477
|
-
sealRelease({ configRoot: appRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot });
|
|
534
|
+
sealRelease({ configRoot: appRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields: rightKitResolution ? registryManifestFields(rightKitResolution.snapshot, RELEASE_TOOL_VERSION) : undefined });
|
|
478
535
|
verifySealedRelease(sealedDir);
|
|
479
536
|
if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
|
|
480
537
|
checkpoint(stateRoot, "sealed");
|
|
@@ -521,7 +578,7 @@ function sqlCipherFeatures(text) {
|
|
|
521
578
|
.filter((value) => /sqlcipher|openssl/i.test(value));
|
|
522
579
|
}
|
|
523
580
|
|
|
524
|
-
function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot }) {
|
|
581
|
+
function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields }) {
|
|
525
582
|
// Every app's right-release.config.mjs names installer/updater artifacts
|
|
526
583
|
// relative to its resolved native target. On a broker-managed host that
|
|
527
584
|
// directory is never populated (the broker owns CARGO_TARGET_DIR and
|
|
@@ -669,6 +726,9 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
|
|
|
669
726
|
version: config.version,
|
|
670
727
|
commit,
|
|
671
728
|
ownershipEvidence,
|
|
729
|
+
// The exact registry resolution (identities, digests, registries, policy revision, observation time, observed latest)
|
|
730
|
+
// this release was built against. Upload and resume consume it; neither resolves again.
|
|
731
|
+
...rightkitRegistryFields,
|
|
672
732
|
qa: qaAdmission,
|
|
673
733
|
// DevTools allowance (owner's fixed design): { mode: "denied" } for every app; content-only only for an allow-listed app.
|
|
674
734
|
devtools: devtoolsEvidence(resolveDevtoolsAllowance({ config, root: configRoot })),
|
package/ownership-approval.mjs
CHANGED
|
@@ -291,13 +291,17 @@ function versionAtLeast(actual, minimum) {
|
|
|
291
291
|
export const TRUST_SYNC_RETIRED = "right-release ownership trust-sync is retired: no release, ownership or QA check needs a human-approved policy bundle any more. The bundle is still fingerprinted; `right-release ownership trust-compare` reports drift as a warning with a semantic diff.";
|
|
292
292
|
|
|
293
293
|
export const POLICY_BUNDLE_DRIFT_WARNING = "ownership policy bundle (scanner source, catalog, registry or dependency allowlist) differs from the bundle pinned in protected trust (warning only; trust-sync is retired)";
|
|
294
|
+
export const CATALOG_DIGEST_DRIFT_WARNING = "ownership catalog digest differs from the digest pinned in protected trust (warning only; the catalog version still meets the protected minimum; trust-sync is retired)";
|
|
294
295
|
export function validateOwnershipFloor(trust, { scannerVersion, catalogVersion, catalogDigest, policyBundleDigest } = {}) {
|
|
295
296
|
const problems = [];
|
|
296
297
|
const warnings = [];
|
|
297
298
|
if (!trust) return { ok: false, problems: ["external ownership trust unavailable"], warnings };
|
|
298
299
|
if (!versionAtLeast(scannerVersion, trust.minimumScannerVersion)) problems.push("scanner version missing, malformed, or below protected minimum");
|
|
299
300
|
if (!versionAtLeast(catalogVersion, trust.minimumCatalogVersion)) problems.push("catalog version missing, malformed, or below protected minimum");
|
|
300
|
-
|
|
301
|
+
// Digest drift is policy-bundle drift (the catalog is part of the bundle): a warning, never a refusal, because
|
|
302
|
+
// trust-sync, the only way to re-pin it, is retired (decisions.md 2026-10-15, docs/OWNERSHIP-ENFORCEMENT.md).
|
|
303
|
+
// The numeric scanner and catalog minimums above stay hard floors.
|
|
304
|
+
if (trust.minimumCatalogDigest && trust.minimumCatalogDigest !== catalogDigest) warnings.push(CATALOG_DIGEST_DRIFT_WARNING);
|
|
301
305
|
// Trust written before the bundle existed has no policyBundle: nothing to compare.
|
|
302
306
|
if (trust.policyBundle?.digest && policyBundleDigest !== undefined && trust.policyBundle.digest !== policyBundleDigest) warnings.push(POLICY_BUNDLE_DRIFT_WARNING);
|
|
303
307
|
return { ok: problems.length === 0, problems, warnings };
|
package/ownership-lockfiles.mjs
CHANGED
|
@@ -59,7 +59,7 @@ function scalar(raw) {
|
|
|
59
59
|
if (/[\[\]{}]/.test(value)) fail("invalid YAML scalar");
|
|
60
60
|
return value.replace(/\s+#.*$/, "");
|
|
61
61
|
}
|
|
62
|
-
function parsePnpmYaml(text) {
|
|
62
|
+
export function parsePnpmYaml(text) {
|
|
63
63
|
const root = Object.create(null), stack = [{ indent: -1, value: root }];
|
|
64
64
|
for (const line of text.split(/\r?\n/)) {
|
|
65
65
|
if (!line.trim() || /^\s*#/.test(line)) continue;
|
package/ownership-verdict.mjs
CHANGED
|
@@ -55,8 +55,9 @@ export function ownershipVerdict(report, trust) {
|
|
|
55
55
|
const scannedRoots = report.metadata.roots?.map((entry) => entry.root) ?? [report.metadata.pathPrefix?.replace(/\/$/, "") || "."];
|
|
56
56
|
if (!noRegistry && (new Set(scannedRoots).size !== scannedRoots.length || sha256([...scannedRoots].sort()) !== sha256([...registration.registration.roots].sort())))
|
|
57
57
|
return refuse("RK_OWNERSHIP_INVALID_REGISTRATION", ["scan roots do not exactly cover protected repository registration"]);
|
|
58
|
-
const floor = noRegistry ? { ok: true, problems: [] } : validateOwnershipFloor(trust, report.metadata);
|
|
58
|
+
const floor = noRegistry ? { ok: true, problems: [], warnings: [] } : validateOwnershipFloor(trust, report.metadata);
|
|
59
59
|
if (!floor.ok) return refuse("RK_OWNERSHIP_POLICY_DOWNGRADE", floor.problems);
|
|
60
|
+
warnings.push(...(floor.warnings ?? []));
|
|
60
61
|
const incomplete = [...report.coverage, ...report.metadata.coverage].filter((entry) => entry.status !== "complete");
|
|
61
62
|
if (incomplete.length) return refuse("RK_OWNERSHIP_INCOMPLETE_COVERAGE", [...new Set(incomplete.map((entry) => entry.path + ": " + entry.reason))]);
|
|
62
63
|
const blocking = report.violations.filter(isBlockingFinding);
|
package/package.json
CHANGED
package/release-state.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { readCachePolicy, resolveCacheLayout } from "./cache-policy.mjs";
|
|
|
6
6
|
import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
7
7
|
import { bufferQaFindings } from "./qa-control-scan.mjs";
|
|
8
8
|
import { sealedManifestAllowsContentDevtools } from "./devtools-policy.mjs";
|
|
9
|
+
import { assertManifestRegistryEvidence } from "./rightkit-registry-resolver.mjs";
|
|
9
10
|
|
|
10
11
|
export function cacheFingerprint({ cargoLockSha256, rustc, target, architecture, features = [] }) {
|
|
11
12
|
const payload = JSON.stringify({
|
|
@@ -117,13 +118,14 @@ export function releaseEnvironment({ root, cacheRoot, platform, architecture, ap
|
|
|
117
118
|
};
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
export function verifySealedRelease(sealedDir) {
|
|
121
|
+
export function verifySealedRelease(sealedDir, options = {}) {
|
|
121
122
|
const manifestPath = path.join(sealedDir, "release-manifest.json");
|
|
122
123
|
if (!existsSync(manifestPath)) throw new Error(`sealed manifest missing: ${manifestPath}`);
|
|
123
124
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
124
125
|
if (manifest.schema !== 1) throw new Error(`unsupported sealed manifest schema: ${manifest.schema}`);
|
|
125
126
|
if (manifest.kind != null && typeof manifest.kind !== "string") throw new Error("sealed manifest kind must be a string when present");
|
|
126
127
|
if (!manifest.checkpoints?.includes("sealed")) throw new Error("release is not sealed");
|
|
128
|
+
assertManifestRegistryEvidence(manifest, options.legacyReleaseIds ? { legacyReleaseIds: options.legacyReleaseIds } : undefined);
|
|
127
129
|
for (const item of manifest.files ?? []) {
|
|
128
130
|
if (typeof item.name !== "string" || path.basename(item.name) !== item.name || item.name === "." || item.name === "..") throw new Error("unsafe sealed artifact name");
|
|
129
131
|
const file = path.join(sealedDir, item.name);
|
package/release.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { MSIX_SIGNING_CONTRACT, assertValidMsixTarget } from "./msix-config.mjs"
|
|
|
25
25
|
import { buildMsix } from "./msix-build.mjs";
|
|
26
26
|
import { msixBuildOptions } from "./msix-target.mjs";
|
|
27
27
|
import { rightKitVersionAllowed } from "./rightkit-version-policy.mjs";
|
|
28
|
+
import { RightKitRegistryError, SNAPSHOT_ENV, loadAdmittedSnapshot, prepareRightKitResolution, rightKitLockInventory } from "./rightkit-registry-resolver.mjs";
|
|
28
29
|
import { collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
|
|
29
30
|
import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
|
|
30
31
|
import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
|
|
@@ -73,7 +74,16 @@ const RIGHTKIT_ALLOWED = buildAllowedVersions(
|
|
|
73
74
|
RIGHTKIT_VERSIONS.legacyNpm,
|
|
74
75
|
RIGHTKIT_VERSIONS.registry?.npm,
|
|
75
76
|
);
|
|
76
|
-
const RIGHTKIT_CARGO_ALLOWED = buildAllowedVersions(
|
|
77
|
+
const RIGHTKIT_CARGO_ALLOWED = buildAllowedVersions(
|
|
78
|
+
RIGHTKIT_VERSIONS.cargo,
|
|
79
|
+
RIGHTKIT_VERSIONS.stagedCargo,
|
|
80
|
+
RIGHTKIT_VERSIONS.legacyCargo,
|
|
81
|
+
RIGHTKIT_VERSIONS.registry?.cargo,
|
|
82
|
+
);
|
|
83
|
+
const RIGHTKIT_OWNERSHIP = JSON.parse(readFileSync(path.resolve(TOOL_ROOT, "rightkit-ownership.json"), "utf8"));
|
|
84
|
+
// Exact RightKit versions proven against the canonical registries for THIS app's locks (see rightkit-registry-resolver.mjs).
|
|
85
|
+
// Exact membership only: a verified version never widens the bundled min..max range.
|
|
86
|
+
let rightKitVerifiedNpm = new Map();
|
|
77
87
|
const FORBIDDEN_RIGHTKIT_SPEC = /^(?:git|file|link|workspace):|github|github\.com/i;
|
|
78
88
|
const WINDOWS_NSIS_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-nsis-v1";
|
|
79
89
|
const WINDOWS_PORTABLE_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-portable-v1";
|
|
@@ -135,6 +145,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
135
145
|
else if (arg === "--skip-checks") opts.skipChecks = true;
|
|
136
146
|
else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
|
|
137
147
|
else if (arg === "--doctor") opts.doctor = true;
|
|
148
|
+
else if (arg === "--admission") opts.admission = true;
|
|
138
149
|
else if (arg === "--resume") opts.resume = true;
|
|
139
150
|
else if (arg === "--resume-from") { opts.resume = true; opts.resumeFrom = args[++i]; }
|
|
140
151
|
else if (arg === "--tier") opts.tier = args[++i];
|
|
@@ -190,11 +201,59 @@ const nativeIos = opts.platform === "ios" && !hasDefaultTauriManifest({ appRoot:
|
|
|
190
201
|
const nativeLayout = nativeIos ? null : resolveNativeCargoLayout({ appRoot: root, config });
|
|
191
202
|
const nativeAssembly = config.nativeAssembly;
|
|
192
203
|
const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
|
|
204
|
+
// RightKit pins are proven against the canonical registries once per release, at build admission by the controller
|
|
205
|
+
// (build-release.mjs). This worker, a resume and the upload consume only that admitted snapshot, through the release's own
|
|
206
|
+
// state root and admission record; a snapshot from anywhere else is refused. The standalone doctor resolves live.
|
|
207
|
+
const rightKitSnapshotFile = process.env[SNAPSHOT_ENV] || null;
|
|
208
|
+
let rightKitResolution;
|
|
209
|
+
try {
|
|
210
|
+
const lockInventory = () => {
|
|
211
|
+
try {
|
|
212
|
+
const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
|
|
213
|
+
let canonicalTarget = config.targets?.[opts.platform];
|
|
214
|
+
try { canonicalTarget = expandPackageRecipeTarget(canonicalTarget, { platform: opts.platform, appRoot: root, config }); } catch { /* reported later */ }
|
|
215
|
+
const inputs = resolveReleaseBuildInputs({ repoRoot, appRoot: root, configPath, config, target: canonicalTarget });
|
|
216
|
+
return rightKitLockInventory({ requiredInputs: inputs.requiredInputs, cargoLockPaths: inputs.cargoLockPaths, nativeLayout: inputs.nativeLayout, config, root });
|
|
217
|
+
} catch {
|
|
218
|
+
return rightKitLockInventory({ nativeLayout, config, root });
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
let boundSnapshot = null;
|
|
222
|
+
if (!opts.doctor) {
|
|
223
|
+
if (!rightKitSnapshotFile) throw new RightKitRegistryError("no-admission", "no admitted RightKit registry snapshot was handed to the release worker; run it through `right-release build`, which admits the release");
|
|
224
|
+
const stateRoot = process.env.RIGHT_RELEASE_STATE_ROOT;
|
|
225
|
+
if (!stateRoot) throw new RightKitRegistryError("no-admission", "RIGHT_RELEASE_STATE_ROOT is not set; the admitted RightKit registry snapshot cannot be located");
|
|
226
|
+
const commit = git(root, ["rev-parse", "HEAD"]);
|
|
227
|
+
boundSnapshot = loadAdmittedSnapshot(stateRoot, { releaseId: `${config.app}-${config.version}-${commit.slice(0, 8)}`, commit, platform: opts.platform, snapshotFile: rightKitSnapshotFile });
|
|
228
|
+
}
|
|
229
|
+
rightKitResolution = await prepareRightKitResolution({
|
|
230
|
+
root,
|
|
231
|
+
extraLocks: lockInventory(),
|
|
232
|
+
versions: RIGHTKIT_VERSIONS,
|
|
233
|
+
ownership: RIGHTKIT_OWNERSHIP,
|
|
234
|
+
releaseVersion: VERSION,
|
|
235
|
+
boundSnapshot,
|
|
236
|
+
// The doctor reports newer versions; run as the admission gate (--admission) it refuses a pin behind latest.
|
|
237
|
+
admission: opts.doctor && opts.admission,
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (!(error instanceof RightKitRegistryError)) throw error;
|
|
241
|
+
fail(`${config.app ?? path.basename(root)} ${error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
|
|
242
|
+
}
|
|
243
|
+
{
|
|
244
|
+
const { newer, tasks, warnings, notes } = rightKitResolution.freshness;
|
|
245
|
+
const out = opts.doctor ? console.log : console.error;
|
|
246
|
+
for (const line of newer) out(`right-release: newer RightKit version (informational): ${line}`);
|
|
247
|
+
for (const line of tasks) out(`right-release: LANE TASK ${line}`);
|
|
248
|
+
for (const line of warnings) console.error(`right-release: WARNING ${line}`);
|
|
249
|
+
for (const line of notes) console.error(`right-release: note ${line}`);
|
|
250
|
+
}
|
|
251
|
+
rightKitVerifiedNpm = rightKitResolution.verified.npm;
|
|
193
252
|
// A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate.
|
|
194
253
|
if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
|
|
195
254
|
await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
|
|
196
255
|
}
|
|
197
|
-
validateRightKitCargoContract(root, RIGHTKIT_CARGO_ALLOWED, config.app ?? path.basename(root));
|
|
256
|
+
validateRightKitCargoContract(root, mergeVerifiedVersions(RIGHTKIT_CARGO_ALLOWED, rightKitResolution.verified.cargo), config.app ?? path.basename(root));
|
|
198
257
|
// One standard dev profile and panic = "unwind" in every app (templates/cargo-profiles.toml).
|
|
199
258
|
assertCargoProfileContract(root, config.app ?? path.basename(root));
|
|
200
259
|
// RightKit QA is mandatory for every app (also SwiftPM apps without a package.json): no QA lane refuses the release.
|
|
@@ -324,6 +383,7 @@ if (opts.doctor) {
|
|
|
324
383
|
console.log(`platform: ${opts.platform}`);
|
|
325
384
|
console.log(`tier: ${opts.tier ?? "<required for release/publish>"}`);
|
|
326
385
|
console.log(`packageManager: ${config.packageManager}`);
|
|
386
|
+
console.log(`rightkit-registry: ${rightKitResolution.summary}`);
|
|
327
387
|
console.log(`workdir: ${workdir}`);
|
|
328
388
|
console.log(`hardeningscan: ${HARDENING_SCAN}`);
|
|
329
389
|
if (sccacheVersion) console.log(`sccache: ${sccacheVersion}`);
|
|
@@ -1050,12 +1110,13 @@ async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
|
|
|
1050
1110
|
fail(`${appName ?? pkg.name ?? "app"} ${name} must come from the published npm package, not ${specifier}`);
|
|
1051
1111
|
}
|
|
1052
1112
|
const allowed = RIGHTKIT_ALLOWED.get(name);
|
|
1053
|
-
if (allowed && !rightKitVersionAllowed(allowed, specifier)) {
|
|
1113
|
+
if (allowed && !rightKitVerifiedNpm.get(name)?.has(specifier) && !rightKitVersionAllowed(allowed, specifier)) {
|
|
1054
1114
|
fail(`${appName ?? pkg.name ?? "app"} ${name} must be one of ${[...allowed].join(", ")}, got ${specifier}`);
|
|
1055
1115
|
}
|
|
1056
1116
|
}
|
|
1057
1117
|
const releaseAllowed = RIGHTKIT_ALLOWED.get("@rightkit/release");
|
|
1058
|
-
|
|
1118
|
+
const releaseSpecifier = pkg.devDependencies?.["@rightkit/release"];
|
|
1119
|
+
if (!releaseAllowed || !(rightKitVerifiedNpm.get("@rightkit/release")?.has(releaseSpecifier) || rightKitVersionAllowed(releaseAllowed, releaseSpecifier))) {
|
|
1059
1120
|
fail(`${appName ?? pkg.name ?? "app"} must depend on a supported @rightkit/release version in devDependencies`);
|
|
1060
1121
|
}
|
|
1061
1122
|
}
|
|
@@ -1459,6 +1520,16 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
|
1459
1520
|
}
|
|
1460
1521
|
}
|
|
1461
1522
|
|
|
1523
|
+
function mergeVerifiedVersions(bundled, verified) {
|
|
1524
|
+
const merged = new Map([...bundled].map(([name, versions]) => [name, new Set(versions)]));
|
|
1525
|
+
for (const [name, versions] of verified) {
|
|
1526
|
+
const set = merged.get(name) ?? new Set();
|
|
1527
|
+
for (const version of versions) set.add(version);
|
|
1528
|
+
merged.set(name, set);
|
|
1529
|
+
}
|
|
1530
|
+
return merged;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1462
1533
|
function buildAllowedVersions(published = {}, staged = {}, legacy = {}, registry = {}) {
|
|
1463
1534
|
const allowed = new Map();
|
|
1464
1535
|
for (const [name, value] of [
|
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
// Registry-bound RightKit pins: at build admission the controller resolves the app's EXACT locked RightKit versions
|
|
2
|
+
// (Cargo.lock entries, pnpm-lock.yaml resolutions) against the canonical registries, so a new version of a known
|
|
3
|
+
// RightKit package needs no new release of this tool.
|
|
4
|
+
//
|
|
5
|
+
// What admission checks, per exact locked name+version of a RightKit-OWNED package (known names only; there is no
|
|
6
|
+
// maintainer-membership proof, so a name first published after this tool is refused until the ownership data lists it):
|
|
7
|
+
// - ownership: named in the bundled ownership/inventory data (a rightkit-* or @rightkit/* prefix alone is not enough)
|
|
8
|
+
// - source: canonical crates.io / npmjs.org registry resolution only (no path, git, tarball or directory overrides)
|
|
9
|
+
// - policy: not revoked, not below the recorded minimum (rightkit-versions.json keeps those policies)
|
|
10
|
+
// - registry: the exact version exists, is not yanked (crates.io) / deprecated (npm), and the registry digest
|
|
11
|
+
// (Cargo index `cksum`, npm `dist.integrity`) equals the lock's digest. That is METADATA agreement between the
|
|
12
|
+
// registry and the lock; it is not proof of the bytes a build consumes (nothing here materializes or hashes packages)
|
|
13
|
+
// - freshness (build admission only): a pin behind the registry's latest (crates.io max_stable_version, npm
|
|
14
|
+
// dist-tags.latest) is refused. A .rightkit/update.json hold is a migration lane task and grants no exception
|
|
15
|
+
// - coverage: every RightKit dependency a manifest declares needs a lock entry for that package name somewhere in the
|
|
16
|
+
// canonical lock inventory (a name-level check, not importer-specific proof)
|
|
17
|
+
// Registry existence proves publication only; every other qualification requirement stays separate.
|
|
18
|
+
//
|
|
19
|
+
// TRUST SCOPE: the admitted snapshot, its admission record and the release-inputs payload are plain JSON files in the
|
|
20
|
+
// release state root. Reuse (worker, resume, seal) checks that they agree with each other, with this release's identity
|
|
21
|
+
// and with the current locks. That is a TRUSTED-LOCAL consistency check, not an authenticated provenance boundary:
|
|
22
|
+
// anyone who can write the state root can forge a consistent set. A resume therefore proves "the recorded admission
|
|
23
|
+
// is internally consistent and still matches the locks", and does NOT prove the registry still says so or that the
|
|
24
|
+
// controller wrote it. Nothing here rewrites a pin.
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { parse as parseToml } from "smol-toml";
|
|
29
|
+
import { isExcludedScanDirectory } from "./cargo-contract.mjs";
|
|
30
|
+
import { resolveSharedCacheRoot } from "./cache-policy.mjs";
|
|
31
|
+
import { parsePnpmYaml } from "./ownership-lockfiles.mjs";
|
|
32
|
+
import { compareVersions } from "./rightkit-sync.mjs";
|
|
33
|
+
|
|
34
|
+
export const SNAPSHOT_SCHEMA = 1;
|
|
35
|
+
export const SNAPSHOT_ENV = "RIGHT_RELEASE_RIGHTKIT_SNAPSHOT";
|
|
36
|
+
export const EVIDENCE_VERSION = 1;
|
|
37
|
+
export const REGISTRIES = Object.freeze({
|
|
38
|
+
cargo: "https://index.crates.io/",
|
|
39
|
+
cargoApi: "https://crates.io/api/v1/crates/",
|
|
40
|
+
npm: "https://registry.npmjs.org/",
|
|
41
|
+
});
|
|
42
|
+
const CRATES_IO_SOURCES = new Set(["registry+https://github.com/rust-lang/crates.io-index", "registry+https://index.crates.io/"]);
|
|
43
|
+
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
44
|
+
const USER_AGENT = "right-release-resolver/1 (https://github.com/bogusyogi/rightkit)";
|
|
45
|
+
const FIX = "run `right-update run` (@rightkit/outdated 0.2.0) to move the pin";
|
|
46
|
+
const DEFAULT_ATTEMPTS = 3;
|
|
47
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
48
|
+
const CONCURRENCY = 6;
|
|
49
|
+
|
|
50
|
+
export class RightKitRegistryError extends Error {
|
|
51
|
+
constructor(code, message, { recoverable = false, problems = [] } = {}) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "RightKitRegistryError";
|
|
54
|
+
this.code = code;
|
|
55
|
+
this.recoverable = recoverable;
|
|
56
|
+
this.problems = problems;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
61
|
+
const stable = (value) => {
|
|
62
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
|
63
|
+
if (value && typeof value === "object") return `{${Object.keys(value).sort().filter((k) => value[k] !== undefined).map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(",")}}`;
|
|
64
|
+
return JSON.stringify(value);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------------------------- ownership + policy
|
|
68
|
+
|
|
69
|
+
/** Owned names per registry, from the bundled versions file and ownership inventory. */
|
|
70
|
+
export function ownedRightKitNames(versions = {}, ownership = {}) {
|
|
71
|
+
const owned = { cargo: new Set(), npm: new Set() };
|
|
72
|
+
for (const key of ["npm", "stagedNpm", "legacyNpm"]) for (const name of Object.keys(versions[key] ?? {})) owned.npm.add(name);
|
|
73
|
+
for (const name of Object.keys(versions.registry?.npm ?? {})) owned.npm.add(name);
|
|
74
|
+
for (const key of ["cargo", "stagedCargo", "legacyCargo"]) for (const name of Object.keys(versions[key] ?? {})) owned.cargo.add(name);
|
|
75
|
+
for (const name of Object.keys(versions.registry?.cargo ?? {})) owned.cargo.add(name);
|
|
76
|
+
for (const entry of ownership.capabilities ?? []) {
|
|
77
|
+
if (typeof entry?.package !== "string") continue;
|
|
78
|
+
(entry.package.startsWith("@") ? owned.npm : owned.cargo).add(entry.package);
|
|
79
|
+
}
|
|
80
|
+
return owned;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const prefixed = (ecosystem, name) => (ecosystem === "cargo" ? name.startsWith("rightkit-") : name.startsWith("@rightkit/"));
|
|
84
|
+
|
|
85
|
+
function recordedVersions(versions, ecosystem, name) {
|
|
86
|
+
const keys = ecosystem === "npm" ? ["npm", "stagedNpm", "legacyNpm"] : ["cargo", "stagedCargo", "legacyCargo"];
|
|
87
|
+
const found = new Set();
|
|
88
|
+
for (const value of [...keys.map((key) => versions[key]?.[name]), versions.registry?.[ecosystem]?.[name]]) {
|
|
89
|
+
for (const version of Array.isArray(value) ? value : value === undefined ? [] : [value]) if (typeof version === "string" && EXACT_SEMVER.test(version)) found.add(version);
|
|
90
|
+
}
|
|
91
|
+
return [...found].sort(compareVersions);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Policy that survives the move to registry resolution: recorded minimum and explicit revocations. */
|
|
95
|
+
function policyProblem(versions, ecosystem, name, version) {
|
|
96
|
+
const revoked = versions.revoked?.[ecosystem]?.[name];
|
|
97
|
+
if ((Array.isArray(revoked) ? revoked : revoked === undefined ? [] : [revoked]).includes(version)) return `${name} ${version} is revoked by release policy`;
|
|
98
|
+
const known = recordedVersions(versions, ecosystem, name);
|
|
99
|
+
if (known.length && EXACT_SEMVER.test(version) && compareVersions(version, known[0]) < 0) return `${name} ${version} is below the recorded minimum ${known[0]}`;
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function policyRevision(versions, releaseVersion = "") {
|
|
104
|
+
return sha256(stable({ versions, releaseVersion })).slice(0, 16);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------------------------- lockfiles
|
|
108
|
+
|
|
109
|
+
function scanFiles(root, wanted) {
|
|
110
|
+
const found = [];
|
|
111
|
+
const visit = (dir) => {
|
|
112
|
+
let entries;
|
|
113
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (entry.isDirectory()) {
|
|
116
|
+
if (!isExcludedScanDirectory(entry.name, path.basename(dir))) visit(path.join(dir, entry.name));
|
|
117
|
+
} else if (entry.isFile() && wanted.has(entry.name)) found.push(path.join(dir, entry.name));
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
visit(root);
|
|
121
|
+
return found.sort();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cargoLockEntries(file) {
|
|
125
|
+
let doc;
|
|
126
|
+
try { doc = parseToml(readFileSync(file, "utf8")); } catch (error) { throw new RightKitRegistryError("bad-lock", `${file} is not a readable Cargo.lock: ${error.message}`); }
|
|
127
|
+
return (doc.package ?? []).map((pkg) => ({ ecosystem: "cargo", name: pkg.name, version: pkg.version, source: pkg.source ?? null, digest: pkg.checksum ? { algo: "sha256", value: String(pkg.checksum).toLowerCase() } : null, lockFile: file }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pnpmLockEntries(file) {
|
|
131
|
+
let lock;
|
|
132
|
+
try { lock = parsePnpmYaml(readFileSync(file, "utf8")); } catch (error) { throw new RightKitRegistryError("bad-lock", `${file} is not a readable pnpm-lock.yaml: ${error.message}`); }
|
|
133
|
+
const entries = [];
|
|
134
|
+
for (const [key, meta] of Object.entries(lock.packages ?? {})) {
|
|
135
|
+
const match = /^(@[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(String(key).replace(/^\//, ""));
|
|
136
|
+
if (!match) continue;
|
|
137
|
+
const resolution = meta && typeof meta === "object" ? meta.resolution : undefined;
|
|
138
|
+
const registryOnly = resolution && typeof resolution === "object" && typeof resolution.integrity === "string"
|
|
139
|
+
&& !resolution.tarball && !resolution.directory && !resolution.type && !resolution.repo;
|
|
140
|
+
entries.push({
|
|
141
|
+
ecosystem: "npm", name: match[1], version: match[2].split(/[(_]/)[0],
|
|
142
|
+
source: registryOnly ? "registry" : null,
|
|
143
|
+
digest: registryOnly ? { algo: "integrity", value: resolution.integrity } : null,
|
|
144
|
+
lockFile: file,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return entries;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Every RightKit-candidate lock entry (owned, or carrying a RightKit prefix) under root. */
|
|
151
|
+
export function collectLockedRightKit(root, owned, { extraLocks = [] } = {}) {
|
|
152
|
+
const lockFiles = [...new Set([...scanFiles(root, new Set(["Cargo.lock", "pnpm-lock.yaml"])), ...extraLocks.map((f) => path.resolve(f)).filter((f) => existsSync(f) && /^(?:Cargo\.lock|pnpm-lock\.yaml)$/.test(path.basename(f)))])];
|
|
153
|
+
const entries = [];
|
|
154
|
+
for (const file of lockFiles) {
|
|
155
|
+
const all = path.basename(file) === "Cargo.lock" ? cargoLockEntries(file) : pnpmLockEntries(file);
|
|
156
|
+
for (const entry of all) if (owned[entry.ecosystem].has(entry.name) || prefixed(entry.ecosystem, entry.name)) entries.push(entry);
|
|
157
|
+
}
|
|
158
|
+
return { lockFiles, entries };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const MANIFEST_DEP_TABLES = ["dependencies", "dev-dependencies", "build-dependencies"];
|
|
162
|
+
const NPM_DEP_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies"];
|
|
163
|
+
|
|
164
|
+
/** Directories whose manifests need lock proof: the app root plus the directory of every canonical lock outside it. */
|
|
165
|
+
function manifestRoots(root, extraLocks) {
|
|
166
|
+
const roots = [path.resolve(root)];
|
|
167
|
+
for (const lock of extraLocks) {
|
|
168
|
+
const dir = path.dirname(path.resolve(lock));
|
|
169
|
+
if (!roots.some((r) => dir === r || dir.startsWith(r + path.sep))) roots.push(dir);
|
|
170
|
+
}
|
|
171
|
+
return roots;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** RightKit dependencies the manifests under root declare (Cargo.toml, package.json): the inputs that need lock proof. */
|
|
175
|
+
export function declaredRightKitDependencies(root, owned, extraLocks = []) {
|
|
176
|
+
const found = [];
|
|
177
|
+
const take = (ecosystem, name, file) => { if (owned[ecosystem].has(name) || prefixed(ecosystem, name)) found.push({ ecosystem, name, file }); };
|
|
178
|
+
for (const file of manifestRoots(root, extraLocks).flatMap((dir) => scanFiles(dir, new Set(["Cargo.toml", "package.json"])))) {
|
|
179
|
+
try {
|
|
180
|
+
if (path.basename(file) === "package.json") {
|
|
181
|
+
const pkg = JSON.parse(readFileSync(file, "utf8"));
|
|
182
|
+
// A git/file/link/workspace specifier never resolves from the registry; the package-contract validator refuses it by name.
|
|
183
|
+
for (const section of NPM_DEP_SECTIONS) for (const [name, spec] of Object.entries(pkg[section] ?? {})) if (!/[:/]/.test(String(spec))) take("npm", name, file);
|
|
184
|
+
} else {
|
|
185
|
+
const doc = parseToml(readFileSync(file, "utf8"));
|
|
186
|
+
const tables = [...MANIFEST_DEP_TABLES.map((t) => doc[t]), doc.workspace?.dependencies, ...Object.values(doc.target ?? {}).flatMap((target) => MANIFEST_DEP_TABLES.map((t) => target?.[t]))];
|
|
187
|
+
for (const table of tables) for (const [key, spec] of Object.entries(table ?? {})) take("cargo", typeof spec === "object" && spec && typeof spec.package === "string" ? spec.package : key, file);
|
|
188
|
+
}
|
|
189
|
+
} catch { /* an unreadable manifest is the manifest validators' finding */ }
|
|
190
|
+
}
|
|
191
|
+
return found;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Declared RightKit dependencies that no lock entry proves: no bundled-only fallback. */
|
|
195
|
+
export function coverageProblems(declared, lockedEntries) {
|
|
196
|
+
const have = new Set(lockedEntries.map((e) => `${e.ecosystem}\0${e.name}`));
|
|
197
|
+
return [...new Set(declared.filter((d) => !have.has(`${d.ecosystem}\0${d.name}`)).map((d) => `${path.basename(d.file)} declares ${d.name} but no ${d.ecosystem === "cargo" ? "Cargo.lock" : "pnpm-lock.yaml"} entry proves it (${d.file}); a missing or incomplete lockfile cannot be admitted`))];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Pre-network refusals for one lock entry. */
|
|
201
|
+
function lockProblems(entry, owned, versions) {
|
|
202
|
+
const label = `${entry.name} ${entry.version}`;
|
|
203
|
+
const where = `(${path.basename(entry.lockFile)})`;
|
|
204
|
+
if (!owned[entry.ecosystem].has(entry.name)) return [`${label} ${where} is not a RightKit-owned package in the ownership data (a ${entry.ecosystem === "cargo" ? "rightkit-" : "@rightkit/"} prefix alone does not establish ownership)`];
|
|
205
|
+
const problems = [];
|
|
206
|
+
if (entry.ecosystem === "cargo" ? !CRATES_IO_SOURCES.has(entry.source) : entry.source !== "registry") problems.push(`${label} ${where} does not resolve from the canonical ${entry.ecosystem === "cargo" ? "crates.io" : "npm"} registry (path, git, tarball and directory sources are refused)`);
|
|
207
|
+
if (!EXACT_SEMVER.test(String(entry.version))) problems.push(`${label} ${where} is not an exact version`);
|
|
208
|
+
if (!entry.digest) problems.push(`${label} ${where} has no ${entry.ecosystem === "cargo" ? "checksum" : "integrity"} in the lock`);
|
|
209
|
+
const policy = policyProblem(versions, entry.ecosystem, entry.name, entry.version);
|
|
210
|
+
if (policy) problems.push(policy);
|
|
211
|
+
return problems;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------------------------- registry client
|
|
215
|
+
|
|
216
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
217
|
+
|
|
218
|
+
function makeClient({ fetchImpl, sleep, attempts, timeoutMs }) {
|
|
219
|
+
async function getJson(url, { accept = "application/json", text = false } = {}) {
|
|
220
|
+
let last;
|
|
221
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
222
|
+
try {
|
|
223
|
+
const response = await fetchImpl(url, { headers: { accept, "user-agent": USER_AGENT }, signal: AbortSignal.timeout(timeoutMs) });
|
|
224
|
+
if (response.status === 404) return { status: 404 };
|
|
225
|
+
if (response.status === 429 || response.status >= 500) { last = new Error(`HTTP ${response.status}`); }
|
|
226
|
+
else if (!response.ok) throw Object.assign(new Error(`HTTP ${response.status}`), { final: true });
|
|
227
|
+
else return { status: 200, body: text ? await response.text() : await response.json() };
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error?.final) throw new RightKitRegistryError("registry-outage", `${url} answered ${error.message}`, { recoverable: true });
|
|
230
|
+
last = error;
|
|
231
|
+
}
|
|
232
|
+
if (attempt < attempts) await sleep(250 * 4 ** (attempt - 1));
|
|
233
|
+
}
|
|
234
|
+
throw new RightKitRegistryError("registry-outage", `${url} unreachable after ${attempts} attempts: ${last?.message ?? last}`, { recoverable: true });
|
|
235
|
+
}
|
|
236
|
+
return { getJson };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function cargoIndexUrl(name) {
|
|
240
|
+
const n = name.toLowerCase();
|
|
241
|
+
const dir = n.length === 1 ? "1" : n.length === 2 ? "2" : n.length === 3 ? `3/${n[0]}` : `${n.slice(0, 2)}/${n.slice(2, 4)}`;
|
|
242
|
+
return `${REGISTRIES.cargo}${dir}/${n}`;
|
|
243
|
+
}
|
|
244
|
+
const npmUrl = (name) => `${REGISTRIES.npm}${name.replace("/", "%2f")}`;
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------------------------- cache (external storage only)
|
|
247
|
+
|
|
248
|
+
/** Cache root under the release tool's configured shared cache root; null when none is configured (never internal disk). */
|
|
249
|
+
export function defaultRegistryCacheDir(env = process.env, platform = process.platform) {
|
|
250
|
+
try { return path.join(resolveSharedCacheRoot({ env, platform }), "rightkit-registry"); } catch { return null; }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function makeCache(dir, warn) {
|
|
254
|
+
const file = (...parts) => path.join(dir, ...parts.map((p) => p.replaceAll("/", "%2f").replaceAll("..", "_")));
|
|
255
|
+
return {
|
|
256
|
+
dir,
|
|
257
|
+
read(...parts) {
|
|
258
|
+
if (!dir) return null;
|
|
259
|
+
try { return JSON.parse(readFileSync(`${file(...parts)}.json`, "utf8")); } catch { return null; }
|
|
260
|
+
},
|
|
261
|
+
write(record, ...parts) {
|
|
262
|
+
if (!dir) return;
|
|
263
|
+
try {
|
|
264
|
+
const target = `${file(...parts)}.json`;
|
|
265
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
266
|
+
const temp = `${target}.tmp-${process.pid}`;
|
|
267
|
+
writeFileSync(temp, `${JSON.stringify(record)}\n`);
|
|
268
|
+
renameSync(temp, target);
|
|
269
|
+
} catch (error) { warn(`registry cache not written (${error.message})`); }
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------------------------- resolution
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Resolve every distinct exact RightKit lock entry once. Returns the snapshot; throws RightKitRegistryError.
|
|
278
|
+
* Options are injectable for tests: fetchImpl, sleep, now, cacheDir.
|
|
279
|
+
*/
|
|
280
|
+
export async function resolveRightKitPins({
|
|
281
|
+
root, extraLocks = [], versions, ownership, releaseVersion = "",
|
|
282
|
+
fetchImpl = globalThis.fetch, sleep = defaultSleep, now = () => Date.now(),
|
|
283
|
+
cacheDir = defaultRegistryCacheDir(), requireLiveLatest = false, attempts = DEFAULT_ATTEMPTS, timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
284
|
+
warn = (message) => console.error(`right-release: ${message}`),
|
|
285
|
+
}) {
|
|
286
|
+
const owned = ownedRightKitNames(versions, ownership);
|
|
287
|
+
const { lockFiles, entries: locked } = collectLockedRightKit(root, owned, { extraLocks });
|
|
288
|
+
const problems = [...locked.flatMap((entry) => lockProblems(entry, owned, versions)), ...coverageProblems(declaredRightKitDependencies(root, owned, extraLocks), locked)];
|
|
289
|
+
if (problems.length) throw new RightKitRegistryError("refused", `RightKit pins refused:\n${[...new Set(problems)].map((p) => `- ${p}`).join("\n")}`, { problems });
|
|
290
|
+
|
|
291
|
+
const distinct = new Map();
|
|
292
|
+
for (const entry of locked) {
|
|
293
|
+
const key = `${entry.ecosystem}\0${entry.name}\0${entry.version}`;
|
|
294
|
+
const known = distinct.get(key);
|
|
295
|
+
if (!known) distinct.set(key, { ...entry, lockFiles: [entry.lockFile] });
|
|
296
|
+
else if (known.digest.value !== entry.digest.value) problems.push(`${entry.name} ${entry.version} is locked with two different digests (${known.lockFiles[0]} and ${entry.lockFile})`);
|
|
297
|
+
else known.lockFiles.push(entry.lockFile);
|
|
298
|
+
}
|
|
299
|
+
if (problems.length) throw new RightKitRegistryError("digest-mismatch", `RightKit pins refused:\n${problems.map((p) => `- ${p}`).join("\n")}`, { problems });
|
|
300
|
+
|
|
301
|
+
const client = makeClient({ fetchImpl, sleep, attempts, timeoutMs });
|
|
302
|
+
const cache = makeCache(cacheDir, warn);
|
|
303
|
+
const observedAt = new Date(now()).toISOString();
|
|
304
|
+
const packuments = new Map();
|
|
305
|
+
const packument = (name) => { if (!packuments.has(name)) packuments.set(name, client.getJson(npmUrl(name), { accept: "application/vnd.npm.install-v1+json" })); return packuments.get(name); };
|
|
306
|
+
const indexes = new Map();
|
|
307
|
+
const index = (name) => { if (!indexes.has(name)) indexes.set(name, client.getJson(cargoIndexUrl(name), { text: true })); return indexes.get(name); };
|
|
308
|
+
|
|
309
|
+
const refused = [];
|
|
310
|
+
const outages = [];
|
|
311
|
+
const verified = [];
|
|
312
|
+
async function verify(entry) {
|
|
313
|
+
const label = `${entry.name} ${entry.version}`;
|
|
314
|
+
let registryDigest;
|
|
315
|
+
try {
|
|
316
|
+
if (entry.ecosystem === "cargo") {
|
|
317
|
+
const response = await index(entry.name);
|
|
318
|
+
if (response.status === 404) { refused.push(`${label} is not published on crates.io (no index entry for ${entry.name})`); return; }
|
|
319
|
+
const rows = String(response.body).split("\n").filter(Boolean).map((line) => { try { return JSON.parse(line); } catch { return null; } });
|
|
320
|
+
const row = rows.find((candidate) => candidate?.vers === entry.version);
|
|
321
|
+
if (!row) { refused.push(`${label} is not published on crates.io (staged or unpublished version)`); return; }
|
|
322
|
+
if (row.yanked) { refused.push(`${label} is yanked on crates.io`); return; }
|
|
323
|
+
registryDigest = String(row.cksum ?? "").toLowerCase();
|
|
324
|
+
} else {
|
|
325
|
+
const response = await packument(entry.name);
|
|
326
|
+
if (response.status === 404) { refused.push(`${label} is not published on npm (no package ${entry.name})`); return; }
|
|
327
|
+
const doc = response.body?.versions?.[entry.version];
|
|
328
|
+
if (!doc) { refused.push(`${label} is not published on npm (staged or unpublished version)`); return; }
|
|
329
|
+
if (doc.deprecated) { refused.push(`${label} is deprecated on npm: ${String(doc.deprecated).slice(0, 200)}`); return; }
|
|
330
|
+
registryDigest = doc.dist?.integrity ?? "";
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (!(error instanceof RightKitRegistryError) || !error.recoverable) throw error;
|
|
334
|
+
outages.push({ label, message: error.message });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (!registryDigest || registryDigest !== entry.digest.value) {
|
|
338
|
+
refused.push(`${label} ${entry.ecosystem === "cargo" ? "checksum" : "integrity"} mismatch: lock has ${entry.digest.value}, registry has ${registryDigest || "none"}`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
verified.push({ ...entryRecord(entry), registryDigest, verifiedAt: observedAt, via: "live" });
|
|
342
|
+
}
|
|
343
|
+
await pool([...distinct.values()], verify);
|
|
344
|
+
if (refused.length) throw new RightKitRegistryError("refused", `RightKit pins refused:\n${[...new Set(refused)].map((p) => `- ${p}`).join("\n")}`, { problems: refused });
|
|
345
|
+
if (outages.length) {
|
|
346
|
+
throw new RightKitRegistryError("registry-outage", `registry unreachable; cannot prove exact RightKit pins (recoverable: retry; there is no offline override for a fresh admission):\n${outages.map((o) => `- ${o.label}: ${o.message}`).join("\n")}`, { recoverable: true, problems: outages.map((o) => o.label) });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Latest per distinct package. `latest` is ALWAYS the value the registry reports now. The cache keeps a separate
|
|
350
|
+
// high-water mark that only guards against stale or out-of-order answers; it is never reported as the live state.
|
|
351
|
+
const latest = [];
|
|
352
|
+
const freshOutages = [];
|
|
353
|
+
const conflicts = [];
|
|
354
|
+
const names = [...new Map([...distinct.values()].map((e) => [`${e.ecosystem}\0${e.name}`, e])).values()];
|
|
355
|
+
const stillPublished = async (entry, version) => {
|
|
356
|
+
if (entry.ecosystem === "npm") {
|
|
357
|
+
const doc = (await packument(entry.name)).body?.versions?.[version];
|
|
358
|
+
return Boolean(doc) && !doc.deprecated;
|
|
359
|
+
}
|
|
360
|
+
const response = await index(entry.name);
|
|
361
|
+
return String(response.body ?? "").split("\n").filter(Boolean).some((line) => { try { const row = JSON.parse(line); return row.vers === version && !row.yanked; } catch { return false; } });
|
|
362
|
+
};
|
|
363
|
+
await pool(names, async (entry) => {
|
|
364
|
+
try {
|
|
365
|
+
let value;
|
|
366
|
+
if (entry.ecosystem === "npm") {
|
|
367
|
+
value = (await packument(entry.name)).body?.["dist-tags"]?.latest;
|
|
368
|
+
} else {
|
|
369
|
+
const response = await client.getJson(`${REGISTRIES.cargoApi}${entry.name}`);
|
|
370
|
+
value = response.body?.crate?.max_stable_version;
|
|
371
|
+
}
|
|
372
|
+
if (typeof value !== "string" || !EXACT_SEMVER.test(value)) throw new RightKitRegistryError("registry-outage", `${entry.name} registry answer carries no usable latest version`, { recoverable: true });
|
|
373
|
+
const previous = cache.read("latest", entry.ecosystem, entry.name);
|
|
374
|
+
const mark = previous?.highWater?.version ?? previous?.latest;
|
|
375
|
+
const row = { ecosystem: entry.ecosystem, name: entry.name, latest: value, observedAt, via: "live" };
|
|
376
|
+
let highWater = { version: value, observedAt };
|
|
377
|
+
if (typeof mark === "string" && EXACT_SEMVER.test(mark) && compareVersions(mark, value) > 0) {
|
|
378
|
+
if (entry.ecosystem === "npm" && await stillPublished(entry, mark)) {
|
|
379
|
+
// An npm `latest` dist-tag is set by the maintainer and may be moved lower on purpose while the higher version stays
|
|
380
|
+
// published: follow the registry. The high-water mark only protects against event-order downgrades of derived answers.
|
|
381
|
+
row.moved = { from: mark, observedAt: previous?.highWater?.observedAt ?? previous?.observedAt };
|
|
382
|
+
} else if (await stillPublished(entry, mark)) {
|
|
383
|
+
// The higher version is still published, so the lower answer is stale or out of order: keep the mark, never downgrade.
|
|
384
|
+
conflicts.push(`${entry.name}: registry reports latest ${value} but ${mark} (observed ${previous?.highWater?.observedAt ?? previous?.observedAt}) is still published; the answer is stale or out of order`);
|
|
385
|
+
highWater = { version: mark, observedAt: previous?.highWater?.observedAt ?? previous?.observedAt };
|
|
386
|
+
row.conflict = { highWater: mark };
|
|
387
|
+
} else {
|
|
388
|
+
// The previously observed version was withdrawn (yanked, deprecated or unpublished): the live state wins, no cache deletion needed.
|
|
389
|
+
row.withdrawn = { version: mark, observedAt: previous?.highWater?.observedAt ?? previous?.observedAt };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
cache.write({ schema: 2, registry: entry.ecosystem, name: entry.name, latest: value, observedAt, highWater }, "latest", entry.ecosystem, entry.name);
|
|
393
|
+
latest.push(row);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (!(error instanceof RightKitRegistryError) || !error.recoverable) throw error;
|
|
396
|
+
const record = cache.read("latest", entry.ecosystem, entry.name);
|
|
397
|
+
if (!requireLiveLatest && record && typeof record.latest === "string") latest.push({ ecosystem: entry.ecosystem, name: entry.name, latest: record.latest, observedAt: record.observedAt, via: "cache", ageMs: Math.max(0, now() - Date.parse(record.observedAt)) });
|
|
398
|
+
else freshOutages.push(`${entry.name}: ${error.message}${record?.latest ? ` (cached latest ${record.latest} observed ${record.observedAt} is last-observed, not current)` : ""}`);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
if (freshOutages.length) throw new RightKitRegistryError("registry-outage", `${requireLiveLatest ? "freshness cannot be established at admission" : "registry unreachable and no cached latest observation"} (recoverable: retry${requireLiveLatest ? "; a cached latest is only last-observed, never current" : ""}):\n${freshOutages.map((p) => `- ${p}`).join("\n")}`, { recoverable: true, problems: freshOutages });
|
|
402
|
+
if (conflicts.length && requireLiveLatest) throw new RightKitRegistryError("eligibility-conflict", `eligibility conflict (recoverable: retry; no cache deletion is needed, a withdrawn version resolves itself):\n${conflicts.map((p) => `- ${p}`).join("\n")}`, { recoverable: true, problems: conflicts });
|
|
403
|
+
|
|
404
|
+
const sort = (rows) => rows.sort((a, b) => `${a.ecosystem}${a.name}${a.version ?? ""}`.localeCompare(`${b.ecosystem}${b.name}${b.version ?? ""}`));
|
|
405
|
+
const snapshot = {
|
|
406
|
+
schema: SNAPSHOT_SCHEMA,
|
|
407
|
+
// admission: latest was read live at build admission; inspection: doctor/informational (never bindable to a build).
|
|
408
|
+
mode: requireLiveLatest ? "admission" : "inspection",
|
|
409
|
+
observedAt,
|
|
410
|
+
registries: { cargo: REGISTRIES.cargo, npm: REGISTRIES.npm },
|
|
411
|
+
policyRevision: policyRevision(versions, releaseVersion),
|
|
412
|
+
lockFiles: lockFiles.map((file) => path.relative(root, file).replaceAll("\\", "/")),
|
|
413
|
+
entries: sort(verified.map((e) => ({ ...e, lockFiles: e.lockFiles.map((file) => path.relative(root, file).replaceAll("\\", "/")) }))),
|
|
414
|
+
latest: sort(latest),
|
|
415
|
+
};
|
|
416
|
+
snapshot.digest = snapshotDigest(snapshot);
|
|
417
|
+
return snapshot;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function entryRecord(entry) {
|
|
421
|
+
return { ecosystem: entry.ecosystem, name: entry.name, version: entry.version, lockDigest: entry.digest.value, lockFiles: entry.lockFiles };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function pool(items, task) {
|
|
425
|
+
const queue = [...items];
|
|
426
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
|
|
427
|
+
while (queue.length) await task(queue.shift());
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------------------------- snapshot
|
|
432
|
+
|
|
433
|
+
export function snapshotDigest(snapshot) {
|
|
434
|
+
const { digest, ...rest } = snapshot;
|
|
435
|
+
return sha256(stable(rest));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function writeSnapshot(file, snapshot) {
|
|
439
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
440
|
+
const temp = `${file}.tmp-${process.pid}`;
|
|
441
|
+
writeFileSync(temp, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
442
|
+
renameSync(temp, file);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function loadSnapshot(file) {
|
|
446
|
+
let snapshot;
|
|
447
|
+
try { snapshot = JSON.parse(readFileSync(file, "utf8")); } catch (error) { throw new RightKitRegistryError("bad-snapshot", `RightKit registry snapshot unreadable (${file}): ${error.message}`); }
|
|
448
|
+
assertSnapshotIntegrity(snapshot);
|
|
449
|
+
return snapshot;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const key3 = (e) => `${e.ecosystem}\0${e.name}\0${e.version}`;
|
|
453
|
+
|
|
454
|
+
/** Structure, digest and internal consistency: entries agree with their lock digest, latest covers exactly the entries. */
|
|
455
|
+
export function assertSnapshotIntegrity(snapshot) {
|
|
456
|
+
if (!snapshot || snapshot.schema !== SNAPSHOT_SCHEMA || !Array.isArray(snapshot.entries) || !Array.isArray(snapshot.latest)) throw new RightKitRegistryError("bad-snapshot", "RightKit registry snapshot has an unsupported shape");
|
|
457
|
+
if (snapshot.digest !== snapshotDigest(snapshot)) throw new RightKitRegistryError("bad-snapshot", "RightKit registry snapshot digest does not match its content");
|
|
458
|
+
const problems = [];
|
|
459
|
+
const seen = new Set();
|
|
460
|
+
for (const entry of snapshot.entries) {
|
|
461
|
+
const label = `${entry?.name} ${entry?.version}`;
|
|
462
|
+
if (!["cargo", "npm"].includes(entry?.ecosystem) || typeof entry.name !== "string" || !EXACT_SEMVER.test(String(entry.version))) { problems.push(`malformed entry ${label}`); continue; }
|
|
463
|
+
if (seen.has(key3(entry))) problems.push(`duplicate entry ${label}`);
|
|
464
|
+
seen.add(key3(entry));
|
|
465
|
+
if (typeof entry.lockDigest !== "string" || !entry.lockDigest || entry.registryDigest !== entry.lockDigest) problems.push(`${label}: registry digest does not equal the lock digest`);
|
|
466
|
+
if (entry.via !== "live") problems.push(`${label}: not verified live`);
|
|
467
|
+
}
|
|
468
|
+
const wanted = new Set(snapshot.entries.map((e) => `${e.ecosystem}\0${e.name}`));
|
|
469
|
+
const rows = new Set();
|
|
470
|
+
for (const row of snapshot.latest) {
|
|
471
|
+
const k = `${row?.ecosystem}\0${row?.name}`;
|
|
472
|
+
if (!wanted.has(k)) problems.push(`latest row for ${row?.name} has no verified entry`);
|
|
473
|
+
else if (rows.has(k)) problems.push(`duplicate latest row for ${row.name}`);
|
|
474
|
+
else if (typeof row.latest !== "string" || !EXACT_SEMVER.test(row.latest)) problems.push(`latest row for ${row.name} has no usable version`);
|
|
475
|
+
rows.add(k);
|
|
476
|
+
}
|
|
477
|
+
for (const k of wanted) if (!rows.has(k)) problems.push(`no latest observation for ${k.split("\0")[1]}`);
|
|
478
|
+
if (problems.length) throw new RightKitRegistryError("bad-snapshot", `RightKit registry snapshot is inconsistent:\n${problems.map((p) => `- ${p}`).join("\n")}`, { problems });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** A snapshot that may be bound to a build: it must be an admission, with every latest read live. */
|
|
482
|
+
export function assertAdmissionSnapshot(snapshot) {
|
|
483
|
+
assertSnapshotIntegrity(snapshot);
|
|
484
|
+
if (snapshot.mode !== "admission") throw new RightKitRegistryError("bad-snapshot", `RightKit registry snapshot was not taken at build admission (mode ${snapshot.mode ?? "<missing>"})`);
|
|
485
|
+
const stale = snapshot.latest.filter((row) => row.via !== "live").map((row) => row.name);
|
|
486
|
+
if (stale.length) throw new RightKitRegistryError("bad-snapshot", `RightKit registry snapshot carries non-live latest observations: ${stale.join(", ")}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The bound snapshot must describe exactly the current locked RightKit inputs: nothing missing, nothing extra, same digests. */
|
|
490
|
+
export function assertSnapshotCoversLocks(snapshot, { root, versions, ownership, extraLocks = [] }) {
|
|
491
|
+
const owned = ownedRightKitNames(versions, ownership);
|
|
492
|
+
const { entries } = collectLockedRightKit(root, owned, { extraLocks });
|
|
493
|
+
const bound = new Map(snapshot.entries.map((e) => [key3(e), e]));
|
|
494
|
+
const problems = [...coverageProblems(declaredRightKitDependencies(root, owned, extraLocks), entries)];
|
|
495
|
+
const current = new Set();
|
|
496
|
+
for (const entry of entries) {
|
|
497
|
+
problems.push(...lockProblems(entry, owned, versions));
|
|
498
|
+
current.add(key3(entry));
|
|
499
|
+
const match = bound.get(key3(entry));
|
|
500
|
+
if (!match) problems.push(`${entry.name} ${entry.version} is locked but not in the bound registry snapshot`);
|
|
501
|
+
else if (entry.digest && match.lockDigest !== entry.digest.value) problems.push(`${entry.name} ${entry.version} lock digest changed since the bound registry snapshot`);
|
|
502
|
+
}
|
|
503
|
+
for (const e of snapshot.entries) if (!current.has(key3(e))) problems.push(`${e.name} ${e.version} is in the bound registry snapshot but not in the locked inputs`);
|
|
504
|
+
if (problems.length) throw new RightKitRegistryError("snapshot-mismatch", `bound RightKit registry snapshot does not match the locked inputs:\n${[...new Set(problems)].map((p) => `- ${p}`).join("\n")}`, { problems });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Canonical lock inventory: the build's own inputs (requiredInputs, native Cargo locks, declared extra locks), not a rebuilt partial list. */
|
|
508
|
+
export function rightKitLockInventory({ requiredInputs = [], cargoLockPaths = [], nativeLayout = null, config = {}, root }) {
|
|
509
|
+
const isLock = (file) => /^(?:Cargo\.lock|pnpm-lock\.yaml)$/.test(path.basename(file));
|
|
510
|
+
return [...new Set([
|
|
511
|
+
...requiredInputs.filter(isLock), ...cargoLockPaths, ...(nativeLayout?.lockCandidates ?? []), ...(nativeLayout?.lockPath ? [nativeLayout.lockPath] : []),
|
|
512
|
+
...(config.cargoLocks ?? []).map((entry) => path.resolve(root, entry)),
|
|
513
|
+
].map((file) => path.resolve(file)))];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---------------------------------------------------------------------------------------------- admitted state (controller-owned)
|
|
517
|
+
|
|
518
|
+
export const SNAPSHOT_FILE = "rightkit-registry-snapshot.json";
|
|
519
|
+
export const ADMISSION_FILE = "rightkit-admission.json";
|
|
520
|
+
const LOCK_PAYLOAD_FILE = "release-inputs.lock.json";
|
|
521
|
+
const fileSha = (file) => sha256(readFileSync(file));
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The controller (build-release) retains, in the release state root it already owns, which snapshot was admitted for
|
|
525
|
+
* which release: release id, commit, platform, the snapshot digest and the hash of the release-inputs lock payload.
|
|
526
|
+
* Workers and resumes accept a snapshot only when this record, the snapshot and the lock payload agree. These are
|
|
527
|
+
* trusted-local files (the state root the controller already uses for release-inputs.lock.json): the check catches
|
|
528
|
+
* mix-ups, stale or foreign state and edits that leave the files inconsistent; it does NOT authenticate them, and a
|
|
529
|
+
* writer of the state root can forge a consistent set. There is no signing layer.
|
|
530
|
+
*/
|
|
531
|
+
export function writeAdmissionRecord(stateRoot, { releaseId, commit, platform, snapshot }) {
|
|
532
|
+
const record = { schema: 1, releaseId, commit, platform, snapshotDigest: snapshot.digest, lockPayloadSha256: fileSha(path.join(stateRoot, LOCK_PAYLOAD_FILE)) };
|
|
533
|
+
const file = path.join(stateRoot, ADMISSION_FILE);
|
|
534
|
+
const temp = `${file}.tmp-${process.pid}`;
|
|
535
|
+
writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`);
|
|
536
|
+
renameSync(temp, file);
|
|
537
|
+
return record;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Load the admitted snapshot of THIS release from its state root, or refuse. `inPreflight` skips the lock payload hash (the controller rewrites it next). */
|
|
541
|
+
export function loadAdmittedSnapshot(stateRoot, { releaseId, commit, platform, snapshotFile = path.join(stateRoot, SNAPSHOT_FILE), inPreflight = false }) {
|
|
542
|
+
const refuse = (message) => { throw new RightKitRegistryError("no-admission", `${message}; the RightKit registry snapshot is only accepted from this release's admitted state (${stateRoot}). Start a fresh build to re-admit.`); };
|
|
543
|
+
if (path.resolve(snapshotFile) !== path.resolve(stateRoot, SNAPSHOT_FILE)) refuse(`snapshot ${snapshotFile} is not the admitted snapshot of this release`);
|
|
544
|
+
const recordFile = path.join(stateRoot, ADMISSION_FILE);
|
|
545
|
+
if (!existsSync(recordFile)) refuse("no admission record exists for this release");
|
|
546
|
+
let record;
|
|
547
|
+
try { record = JSON.parse(readFileSync(recordFile, "utf8")); } catch { refuse("the admission record is unreadable"); }
|
|
548
|
+
if (record?.schema !== 1 || record.releaseId !== releaseId || record.commit !== commit || record.platform !== platform) refuse(`the admission record belongs to another release (${record?.releaseId ?? "?"} ${String(record?.commit ?? "").slice(0, 8)} ${record?.platform ?? "?"})`);
|
|
549
|
+
if (!existsSync(snapshotFile)) refuse("the admitted snapshot file is missing");
|
|
550
|
+
const snapshot = loadSnapshot(snapshotFile);
|
|
551
|
+
assertAdmissionSnapshot(snapshot);
|
|
552
|
+
if (snapshot.digest !== record.snapshotDigest) refuse("the snapshot is not the one admitted for this release (digest differs from the admission record)");
|
|
553
|
+
if (!inPreflight) {
|
|
554
|
+
const payloadFile = path.join(stateRoot, LOCK_PAYLOAD_FILE);
|
|
555
|
+
if (!existsSync(payloadFile) || fileSha(payloadFile) !== record.lockPayloadSha256) refuse("the release input identity changed since admission (release-inputs.lock.json)");
|
|
556
|
+
const payload = JSON.parse(readFileSync(payloadFile, "utf8"));
|
|
557
|
+
if (payload.releaseId !== releaseId || payload.commit !== commit || payload.platform !== platform) refuse("the release input identity names another release");
|
|
558
|
+
}
|
|
559
|
+
return snapshot;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function verifiedVersionMaps(snapshot) {
|
|
563
|
+
const maps = { cargo: new Map(), npm: new Map() };
|
|
564
|
+
for (const entry of snapshot.entries) {
|
|
565
|
+
const set = maps[entry.ecosystem].get(entry.name) ?? new Set();
|
|
566
|
+
set.add(entry.version);
|
|
567
|
+
maps[entry.ecosystem].set(entry.name, set);
|
|
568
|
+
}
|
|
569
|
+
return maps;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------------------------------------- freshness + holds
|
|
573
|
+
|
|
574
|
+
function matchesPattern(name, pattern) {
|
|
575
|
+
if (pattern.startsWith("/")) {
|
|
576
|
+
try { return new RegExp(pattern.replace(/^\/|\/[a-z]*$/g, "")).test(name); } catch { return false; }
|
|
577
|
+
}
|
|
578
|
+
return name === pattern;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Holds named in the nearest .rightkit/update.json (app root up to the repository boundary). A hold is a migration
|
|
582
|
+
* lane task for right-update; it is reported next to a stale pin and never grants permission to seal it.
|
|
583
|
+
* Only holds the file itself declares are read (policy.held, policy.tier.held); no built-in pattern is applied here.
|
|
584
|
+
*/
|
|
585
|
+
export function readUpdateHolds(root) {
|
|
586
|
+
let dir = path.resolve(root);
|
|
587
|
+
for (;;) {
|
|
588
|
+
const file = path.join(dir, ".rightkit", "update.json");
|
|
589
|
+
if (existsSync(file)) {
|
|
590
|
+
try {
|
|
591
|
+
const policy = JSON.parse(readFileSync(file, "utf8")).policy ?? {};
|
|
592
|
+
return { file, names: Array.isArray(policy.held) ? policy.held.filter((v) => typeof v === "string") : [], patterns: Array.isArray(policy.tier?.held) ? policy.tier.held.filter((v) => typeof v === "string") : [] };
|
|
593
|
+
} catch (error) {
|
|
594
|
+
return { file, names: [], patterns: [], malformed: error.message };
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (existsSync(path.join(dir, ".git")) || path.dirname(dir) === dir) return null;
|
|
598
|
+
dir = path.dirname(dir);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function holdFor(holds, name) {
|
|
603
|
+
if (!holds) return null;
|
|
604
|
+
if (holds.names.includes(name)) return "policy.held";
|
|
605
|
+
const pattern = holds.patterns.find((p) => matchesPattern(name, p));
|
|
606
|
+
return pattern ? `tier.held ${pattern}` : null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Pure verdict over a snapshot's observed latest values.
|
|
611
|
+
* - enforce (admission): EVERY pin behind latest is refused, held or not. A hold is a migration lane task
|
|
612
|
+
* (`tasks`), never permission to seal a stale pin; no exception is derived from right-update's hold list.
|
|
613
|
+
* - otherwise (doctor): the same rows are informational `newer` lines.
|
|
614
|
+
*/
|
|
615
|
+
export function evaluateRightKitFreshness(snapshot, holds, { enforce = false } = {}) {
|
|
616
|
+
const refusals = [];
|
|
617
|
+
const newer = [];
|
|
618
|
+
const tasks = [];
|
|
619
|
+
const warnings = [];
|
|
620
|
+
const notes = [];
|
|
621
|
+
if (holds?.malformed) warnings.push(`${holds.file} is unreadable (${holds.malformed}); no holds read`);
|
|
622
|
+
const newest = new Map(snapshot.latest.map((l) => [`${l.ecosystem}\0${l.name}`, l]));
|
|
623
|
+
for (const entry of snapshot.entries) {
|
|
624
|
+
const seen = newest.get(`${entry.ecosystem}\0${entry.name}`);
|
|
625
|
+
if (!seen || compareVersions(entry.version, seen.latest) >= 0) continue;
|
|
626
|
+
const age = seen.via === "cache" ? ` (latest from cached observation ${seen.observedAt}, ${formatAge(seen.ageMs)} old; last-observed, not current)` : "";
|
|
627
|
+
const hold = holdFor(holds, entry.name);
|
|
628
|
+
const line = `${entry.name} pinned ${entry.version} -> latest ${seen.latest}${age}: ${FIX}`;
|
|
629
|
+
if (hold) tasks.push(`${entry.name} pinned ${entry.version}, latest ${seen.latest}: held by .rightkit/update.json (${hold}); migration lane task, not current and not permission to seal`);
|
|
630
|
+
(enforce ? refusals : newer).push(hold ? `${line} [held by .rightkit/update.json (${hold}): a hold is a lane task, not permission to seal stale]` : line);
|
|
631
|
+
}
|
|
632
|
+
for (const l of snapshot.latest) {
|
|
633
|
+
if (l.via === "cache") notes.push(`${l.name} latest ${l.latest} taken from cache, ${formatAge(l.ageMs)} old`);
|
|
634
|
+
if (l.conflict) notes.push(`${l.name}: registry reports ${l.latest} but ${l.conflict.highWater} is still published (stale or out-of-order answer)`);
|
|
635
|
+
if (l.moved) notes.push(`${l.name}: the npm latest tag moved from ${l.moved.from} to ${l.latest}; the registry is followed`);
|
|
636
|
+
if (l.withdrawn) notes.push(`${l.name}: previously observed latest ${l.withdrawn.version} was withdrawn; the live registry state (${l.latest}) is used`);
|
|
637
|
+
}
|
|
638
|
+
return { refusals, newer, tasks, warnings, notes };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function formatAge(ms) {
|
|
642
|
+
const minutes = Math.round((ms ?? 0) / 60000);
|
|
643
|
+
if (minutes < 120) return `${minutes} minutes`;
|
|
644
|
+
const hours = Math.round(minutes / 60);
|
|
645
|
+
return hours < 72 ? `${hours} hours` : `${Math.round(hours / 24)} days`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---------------------------------------------------------------------------------------------- orchestration
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* One entry point for the doctor, the build and the release worker.
|
|
652
|
+
* - snapshotFile + reuse: consume that bound snapshot (no network) after checking it covers the current locks.
|
|
653
|
+
* - otherwise resolve live; with snapshotFile + write, bind the result there for later phases.
|
|
654
|
+
* The freshness verdict is recomputed from the snapshot every time, so a changed hold takes effect on resume.
|
|
655
|
+
*/
|
|
656
|
+
export async function prepareRightKitResolution({ root, snapshotFile = null, boundSnapshot = null, reuse = false, write = false, admission = false, env = process.env, ...options }) {
|
|
657
|
+
const { versions, ownership } = options;
|
|
658
|
+
let snapshot;
|
|
659
|
+
let source;
|
|
660
|
+
if (boundSnapshot) {
|
|
661
|
+
assertAdmissionSnapshot(boundSnapshot);
|
|
662
|
+
snapshot = boundSnapshot;
|
|
663
|
+
assertSnapshotCoversLocks(snapshot, { root, versions, ownership, extraLocks: options.extraLocks });
|
|
664
|
+
source = "bound";
|
|
665
|
+
} else if (reuse) {
|
|
666
|
+
// A resume or a worker never replaces frozen evidence: a missing admitted snapshot is refused, not re-resolved.
|
|
667
|
+
throw new RightKitRegistryError("snapshot-missing", "the admitted RightKit registry snapshot is missing; refusing to resolve again over frozen evidence. Start a fresh build to re-admit.");
|
|
668
|
+
} else {
|
|
669
|
+
snapshot = await resolveRightKitPins({ root, cacheDir: options.cacheDir ?? defaultRegistryCacheDir(env), requireLiveLatest: admission, ...options });
|
|
670
|
+
source = "resolved";
|
|
671
|
+
}
|
|
672
|
+
// Freshness is an admission gate. A bound snapshot is frozen evidence: seal, resume and upload never re-check it,
|
|
673
|
+
// so a publication after admission neither mutates nor restarts an admitted build.
|
|
674
|
+
const enforce = admission && source === "resolved";
|
|
675
|
+
const freshness = evaluateRightKitFreshness(snapshot, readUpdateHolds(root), { enforce });
|
|
676
|
+
if (freshness.refusals.length) {
|
|
677
|
+
throw new RightKitRegistryError("stale-pin", `RightKit pins are behind the latest published version (build admission refused):\n${freshness.refusals.map((p) => `- ${p}`).join("\n")}`, { problems: freshness.refusals });
|
|
678
|
+
}
|
|
679
|
+
if (source === "resolved" && snapshotFile && write) writeSnapshot(snapshotFile, snapshot);
|
|
680
|
+
return { snapshot, source, freshness, verified: verifiedVersionMaps(snapshot), summary: summarizeSnapshot(snapshot, source) };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export function summarizeSnapshot(snapshot, source) {
|
|
684
|
+
const count = (eco) => snapshot.entries.filter((e) => e.ecosystem === eco).length;
|
|
685
|
+
const how = source === "bound" ? "bound snapshot (trusted-local record, not re-checked against the registry, not authenticated)" : "resolved snapshot";
|
|
686
|
+
return `${snapshot.entries.length} exact pins verified against the registries (cargo ${count("cargo")}, npm ${count("npm")}); ${how} ${snapshot.digest.slice(0, 12)} observed ${snapshot.observedAt}; policy ${snapshot.policyRevision}`;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Evidence block for the sealed manifest. Metadata agreement (lock digest = registry digest) only, not byte proof. */
|
|
690
|
+
export function registryEvidence(snapshot) {
|
|
691
|
+
return { schema: EVIDENCE_VERSION, digest: snapshot.digest, snapshot };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Manifest fields a new seal carries: the evidence and an explicit marker that makes its absence detectable. */
|
|
695
|
+
export function registryManifestFields(snapshot, toolVersion) {
|
|
696
|
+
return { rightkitRegistryEvidence: EVIDENCE_VERSION, ...(toolVersion ? { rightReleaseVersion: toolVersion } : {}), rightkitRegistry: registryEvidence(snapshot) };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export const LEGACY_RELEASES_ENV = "RIGHT_RELEASE_LEGACY_REGISTRY_RELEASES";
|
|
700
|
+
const NEW_SEAL_FROM = "0.6.0";
|
|
701
|
+
const legacyReleaseIdsFromEnv = (env = process.env) => String(env[LEGACY_RELEASES_ENV] ?? "").split(",").map((v) => v.trim()).filter(Boolean);
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* A manifest must carry versioned registry evidence. New seals record `rightReleaseVersion` (>= 0.6.0) with the
|
|
705
|
+
* evidence; stripping the evidence from such a seal is refused whatever else is set. Only an explicitly identified
|
|
706
|
+
* pre-0.6.0 artifact is exempt: its manifest records no tool version (or one below 0.6.0) AND its release id is
|
|
707
|
+
* named in `legacyReleaseIds` / RIGHT_RELEASE_LEGACY_REGISTRY_RELEASES. There is no global switch.
|
|
708
|
+
*/
|
|
709
|
+
export function assertManifestRegistryEvidence(manifest, { legacyReleaseIds = legacyReleaseIdsFromEnv() } = {}) {
|
|
710
|
+
const marker = manifest?.rightkitRegistryEvidence;
|
|
711
|
+
const evidence = manifest?.rightkitRegistry;
|
|
712
|
+
const recorded = manifest?.rightReleaseVersion;
|
|
713
|
+
if (recorded !== undefined && !(typeof recorded === "string" && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(recorded))) {
|
|
714
|
+
throw new Error(`sealed manifest rightReleaseVersion is malformed (${JSON.stringify(recorded)}); refusing (only an absent or valid pre-${NEW_SEAL_FROM} version is legacy)`);
|
|
715
|
+
}
|
|
716
|
+
if (marker === undefined && evidence === undefined) {
|
|
717
|
+
let newSeal = false;
|
|
718
|
+
try { newSeal = typeof recorded === "string" && compareVersions(recorded, NEW_SEAL_FROM) >= 0; } catch { newSeal = false; }
|
|
719
|
+
if (newSeal) throw new Error(`sealed manifest was produced by @rightkit/release ${recorded} but carries no RightKit registry evidence; refusing (a stripped new seal is never a legacy seal)`);
|
|
720
|
+
if (typeof manifest?.releaseId === "string" && legacyReleaseIds.includes(manifest.releaseId)) return { legacy: true };
|
|
721
|
+
throw new Error(`sealed manifest carries no RightKit registry evidence; refusing (only a pre-${NEW_SEAL_FROM} release named in ${LEGACY_RELEASES_ENV} is exempt)`);
|
|
722
|
+
}
|
|
723
|
+
if (marker !== EVIDENCE_VERSION) throw new Error(`sealed manifest registry evidence marker must be ${EVIDENCE_VERSION}, got ${marker === undefined ? "<missing>" : JSON.stringify(marker)}`);
|
|
724
|
+
assertRegistryEvidence(evidence);
|
|
725
|
+
return { legacy: false };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export function assertRegistryEvidence(evidence) {
|
|
729
|
+
if (!evidence || evidence.schema !== EVIDENCE_VERSION) throw new Error("sealed manifest rightkitRegistry evidence is missing or has an unsupported schema");
|
|
730
|
+
if (evidence.digest !== evidence.snapshot?.digest) throw new Error("sealed manifest rightkitRegistry digest does not match its snapshot");
|
|
731
|
+
try { assertAdmissionSnapshot(evidence.snapshot); } catch (error) { throw new Error(`sealed manifest rightkitRegistry rejected: ${error.message}`); }
|
|
732
|
+
}
|
package/rightkit-versions.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@rightkit/qa": "0.6.1",
|
|
33
33
|
"@rightkit/qa-chrome": "0.1.11",
|
|
34
34
|
"@rightkit/react-shell": "0.7.2",
|
|
35
|
-
"@rightkit/release": "0.
|
|
35
|
+
"@rightkit/release": "0.6.0",
|
|
36
36
|
"@rightkit/settings-ui": "0.7.0",
|
|
37
37
|
"@rightkit/tauri": "0.3.0",
|
|
38
38
|
"@rightkit/tauri-shell": "0.1.2",
|
|
@@ -224,7 +224,8 @@
|
|
|
224
224
|
"0.3.0",
|
|
225
225
|
"0.4.0",
|
|
226
226
|
"0.4.1",
|
|
227
|
-
"0.5.0"
|
|
227
|
+
"0.5.0",
|
|
228
|
+
"0.5.1"
|
|
228
229
|
],
|
|
229
230
|
"@rightkit/qa": [
|
|
230
231
|
"0.1.0",
|
|
@@ -480,7 +481,7 @@
|
|
|
480
481
|
"rightkit-qa-driver": "0.7.0",
|
|
481
482
|
"rightkit-record": "0.3.3",
|
|
482
483
|
"rightkit-review": "0.4.0",
|
|
483
|
-
"rightkit-sandbox": "0.6.
|
|
484
|
+
"rightkit-sandbox": "0.6.1",
|
|
484
485
|
"rightkit-search": "0.4.0",
|
|
485
486
|
"rightkit-send": "0.2.0",
|
|
486
487
|
"rightkit-sidecar": "0.3.0",
|
|
@@ -572,7 +573,7 @@
|
|
|
572
573
|
"rightkit-qts-ggml-sys": "0.1.2",
|
|
573
574
|
"rightkit-record": "0.3.3",
|
|
574
575
|
"rightkit-review": "0.4.0",
|
|
575
|
-
"rightkit-sandbox": "0.6.
|
|
576
|
+
"rightkit-sandbox": "0.6.1",
|
|
576
577
|
"rightkit-search": "0.4.0",
|
|
577
578
|
"rightkit-secrets": "0.2.0",
|
|
578
579
|
"rightkit-send": "0.2.0",
|
|
@@ -618,7 +619,7 @@
|
|
|
618
619
|
"@rightkit/qa": "0.6.1",
|
|
619
620
|
"@rightkit/qa-chrome": "0.1.11",
|
|
620
621
|
"@rightkit/react-shell": "0.7.2",
|
|
621
|
-
"@rightkit/release": "0.5.
|
|
622
|
+
"@rightkit/release": "0.5.1",
|
|
622
623
|
"@rightkit/settings-ui": "0.7.0",
|
|
623
624
|
"@rightkit/shell": "0.1.1",
|
|
624
625
|
"@rightkit/tauri": "0.3.0",
|
|
@@ -762,7 +763,8 @@
|
|
|
762
763
|
"0.3.3",
|
|
763
764
|
"0.3.5",
|
|
764
765
|
"0.4.0",
|
|
765
|
-
"0.5.0"
|
|
766
|
+
"0.5.0",
|
|
767
|
+
"0.6.0"
|
|
766
768
|
],
|
|
767
769
|
"rightkit-shell": [
|
|
768
770
|
"0.1.0",
|