@rightkit/release 0.2.21 → 0.2.23

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.
@@ -0,0 +1,68 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+
3
+ import { verifyRuntimeArtifactEnvelope } from "./runtime-artifact-manifest.mjs";
4
+
5
+ const ASR_AUTHORITY = "heardright";
6
+ const ASR_CONSUMER = "scraperight";
7
+ const ASR_KIND = "asr-model";
8
+
9
+ export function assertAsrArtifactAdoption({ authorityEnvelope, consumerEnvelope, publicKey, trustedKeyId }) {
10
+ const authority = verifyTrusted(authorityEnvelope, publicKey, trustedKeyId);
11
+ const consumer = verifyTrusted(consumerEnvelope, publicKey, trustedKeyId);
12
+
13
+ assertManifestRole(authority, ASR_AUTHORITY, "authority");
14
+ assertManifestRole(consumer, ASR_CONSUMER, "consumer");
15
+ for (const manifest of [authority, consumer]) {
16
+ if (manifest.artifactKind !== ASR_KIND) throw new Error("ASR adoption manifest must be an asr-model");
17
+ if (manifest.promotion.authorityAppKey !== ASR_AUTHORITY) throw new Error("ASR promotion authority must be heardright");
18
+ }
19
+
20
+ assertSame(authority.target, consumer.target, "target");
21
+ assertSame(authority.object.filename, consumer.object.filename, "object.filename");
22
+ assertSame(authority.object.sha256, consumer.object.sha256, "object.sha256");
23
+ assertSame(authority.object.sizeBytes, consumer.object.sizeBytes, "object.sizeBytes");
24
+ for (const field of ["runtime", "model", "tokenizer", "preprocessing", "license", "provenance"]) {
25
+ assertSame(authority.versions[field], consumer.versions[field], `versions.${field}`);
26
+ }
27
+ assertSame(authority.provenance, consumer.provenance, "provenance");
28
+ assertSame(authority.promotion, consumer.promotion, "promotion");
29
+ return {
30
+ sha256: authority.object.sha256,
31
+ promotionId: authority.promotion.promotionId,
32
+ target: authority.target,
33
+ };
34
+ }
35
+
36
+ export function assertAsrAdapterPair(authority, consumer) {
37
+ assertAdapter(authority, ASR_AUTHORITY, "authority");
38
+ assertAdapter(consumer, ASR_CONSUMER, "consumer");
39
+ for (const [field, expected] of [
40
+ ["authorityAppKey", ASR_AUTHORITY],
41
+ ["artifactKind", ASR_KIND],
42
+ ["delivery", "private-r2"],
43
+ ["entitlementTier", "pro"],
44
+ ]) {
45
+ assertSame(authority[field], expected, `authority adapter ${field}`);
46
+ assertSame(consumer[field], expected, `consumer adapter ${field}`);
47
+ }
48
+ return true;
49
+ }
50
+
51
+ function verifyTrusted(envelope, publicKey, trustedKeyId) {
52
+ if (envelope?.signature?.keyId !== trustedKeyId) throw new Error("untrusted runtime artifact key id");
53
+ return verifyRuntimeArtifactEnvelope(envelope, publicKey);
54
+ }
55
+
56
+ function assertManifestRole(manifest, appKey, role) {
57
+ if (manifest.entitlement.appKey !== appKey) throw new Error(`${role} manifest must belong to ${appKey}`);
58
+ }
59
+
60
+ function assertAdapter(value, appKey, role) {
61
+ if (!value || value.schema !== 1 || value.appKey !== appKey || value.role !== role) {
62
+ throw new Error(`invalid ${role} ASR adapter`);
63
+ }
64
+ }
65
+
66
+ function assertSame(left, right, field) {
67
+ if (!isDeepStrictEqual(left, right)) throw new Error(`ASR drift: ${field}`);
68
+ }
@@ -0,0 +1,122 @@
1
+ import assert from "node:assert/strict";
2
+ import { generateKeyPairSync } from "node:crypto";
3
+ import test from "node:test";
4
+
5
+ import { assertAsrAdapterPair, assertAsrArtifactAdoption } from "./asr-artifact-adoption.mjs";
6
+ import { buildRuntimeArtifactManifest, signRuntimeArtifactManifest } from "./runtime-artifact-manifest.mjs";
7
+
8
+ const digest = "a".repeat(64);
9
+ const notice = "b".repeat(64);
10
+ const evidence = "c".repeat(64);
11
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
12
+
13
+ function envelope(appKey, overrides = {}) {
14
+ const manifest = buildRuntimeArtifactManifest({
15
+ artifactKind: "asr-model",
16
+ entitlement: { appKey, tier: "pro" },
17
+ distribution: { delivery: "private-r2", bucket: "rightapps-updates" },
18
+ target: { os: "windows", arch: "x86_64" },
19
+ filename: "parakeet-unified-en-0.6b.tar.zst",
20
+ sha256: digest,
21
+ sizeBytes: 1234,
22
+ versions: {
23
+ runtime: "rightkit-asr@0.1.0",
24
+ model: "parakeet-unified-en-0.6b",
25
+ tokenizer: "sha256:tokenizer-v1",
26
+ preprocessing: "heardright-frontend-v1",
27
+ license: "CC-BY-4.0",
28
+ provenance: "nvidia/parakeet-tdt-0.6b-v3",
29
+ },
30
+ provenance: {
31
+ source: "https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3",
32
+ sourceRevision: "revision-1",
33
+ licenseId: "CC-BY-4.0",
34
+ noticeSha256: notice,
35
+ },
36
+ promotion: {
37
+ authorityAppKey: "heardright",
38
+ promotionId: "hr-asr-2026-07-14",
39
+ promotedAt: "2026-07-14T00:00:00.000Z",
40
+ evidenceSha256: evidence,
41
+ },
42
+ ...overrides,
43
+ });
44
+ return signRuntimeArtifactManifest(manifest, privateKey, "rightkit-runtime-artifacts-2026-07");
45
+ }
46
+
47
+ test("accepts app-scoped pointers with one exact HeardRight-certified ASR identity", () => {
48
+ const result = assertAsrArtifactAdoption({
49
+ authorityEnvelope: envelope("heardright"),
50
+ consumerEnvelope: envelope("scraperight"),
51
+ publicKey,
52
+ trustedKeyId: "rightkit-runtime-artifacts-2026-07",
53
+ });
54
+
55
+ assert.equal(result.sha256, digest);
56
+ assert.equal(result.promotionId, "hr-asr-2026-07-14");
57
+ });
58
+
59
+ test("rejects a validly signed ScrapeRight manifest with ASR drift", () => {
60
+ const drifted = envelope("scraperight", {
61
+ versions: {
62
+ runtime: "rightkit-asr@0.1.1",
63
+ model: "parakeet-unified-en-0.6b",
64
+ tokenizer: "sha256:tokenizer-v1",
65
+ preprocessing: "heardright-frontend-v1",
66
+ license: "CC-BY-4.0",
67
+ provenance: "nvidia/parakeet-tdt-0.6b-v3",
68
+ },
69
+ });
70
+
71
+ assert.throws(() => assertAsrArtifactAdoption({
72
+ authorityEnvelope: envelope("heardright"),
73
+ consumerEnvelope: drifted,
74
+ publicKey,
75
+ trustedKeyId: "rightkit-runtime-artifacts-2026-07",
76
+ }), /ASR drift: versions\.runtime/);
77
+ });
78
+
79
+ test("rejects wrong roles, authority, target, signature key, and artifact kind", () => {
80
+ const base = {
81
+ authorityEnvelope: envelope("heardright"),
82
+ consumerEnvelope: envelope("scraperight"),
83
+ publicKey,
84
+ trustedKeyId: "rightkit-runtime-artifacts-2026-07",
85
+ };
86
+ assert.throws(() => assertAsrArtifactAdoption({ ...base, authorityEnvelope: envelope("scraperight") }), /authority manifest must belong to heardright/);
87
+ assert.throws(() => assertAsrArtifactAdoption({ ...base, consumerEnvelope: envelope("heardright") }), /consumer manifest must belong to scraperight/);
88
+ assert.throws(() => assertAsrArtifactAdoption({ ...base, trustedKeyId: "rotated-without-app-update" }), /untrusted runtime artifact key id/);
89
+ assert.throws(() => assertAsrArtifactAdoption({
90
+ ...base,
91
+ consumerEnvelope: envelope("scraperight", { target: { os: "darwin", arch: "aarch64" } }),
92
+ }), /ASR drift: target/);
93
+ assert.throws(() => assertAsrArtifactAdoption({
94
+ ...base,
95
+ consumerEnvelope: envelope("scraperight", { artifactKind: "ocr-model" }),
96
+ }), /must be an asr-model/);
97
+ });
98
+
99
+ test("rejects a consumer signed by a different key", () => {
100
+ const other = generateKeyPairSync("ed25519");
101
+ const consumer = signRuntimeArtifactManifest(
102
+ envelope("scraperight").manifest,
103
+ other.privateKey,
104
+ "rightkit-runtime-artifacts-2026-07",
105
+ );
106
+ assert.throws(() => assertAsrArtifactAdoption({
107
+ authorityEnvelope: envelope("heardright"),
108
+ consumerEnvelope: consumer,
109
+ publicKey,
110
+ trustedKeyId: "rightkit-runtime-artifacts-2026-07",
111
+ }), /signature verification failed/);
112
+ });
113
+
114
+ test("locks the thin app adapters to HeardRight authority and private Pro delivery", () => {
115
+ const authority = {
116
+ schema: 1, appKey: "heardright", role: "authority", authorityAppKey: "heardright",
117
+ artifactKind: "asr-model", delivery: "private-r2", entitlementTier: "pro",
118
+ };
119
+ const consumer = { ...authority, appKey: "scraperight", role: "consumer" };
120
+ assert.equal(assertAsrAdapterPair(authority, consumer), true);
121
+ assert.throws(() => assertAsrAdapterPair(authority, { ...consumer, authorityAppKey: "scraperight" }), /ASR drift/);
122
+ });
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
 
6
- const SKIP_DIRECTORIES = new Set([".cache", ".git", ".claude", ".worktrees", "node_modules", "target", "vendor"]);
6
+ const SKIP_DIRECTORIES = new Set([".audit", ".cache", ".git", ".claude", ".worktrees", "node_modules", "target", "vendor"]);
7
7
  const CRATES_IO_SOURCES = new Set([
8
8
  "registry+https://github.com/rust-lang/crates.io-index",
9
9
  "registry+https://index.crates.io/",
@@ -101,11 +101,34 @@ function assertNoRightKitOverrides(parsed, label, source) {
101
101
  }
102
102
  }
103
103
 
104
+ // `tomllib` is Python 3.11+. Windows pins `py -3.11`, but a bare `python3` on macOS is the system
105
+ // 3.9, which has no tomllib — so every app's release:doctor died on a stock Mac, and reported the
106
+ // manifest as invalid TOML when the real problem was the interpreter. Probe for one that can.
107
+ let tomlRunner;
108
+ function resolveTomlRunner() {
109
+ if (tomlRunner) return tomlRunner;
110
+ const probe = "import sys,tomllib";
111
+ const candidates =
112
+ process.platform === "win32"
113
+ ? [["py", ["-3.11"]], ["py", ["-3"]], ["python", []]]
114
+ : [["python3.13", []], ["python3.12", []], ["python3.11", []], ["python3", []]];
115
+ for (const [command, prefix] of candidates) {
116
+ const check = spawnSync(command, [...prefix, "-c", probe], { encoding: "utf8", windowsHide: true });
117
+ if (check.status === 0) {
118
+ tomlRunner = { command, prefix };
119
+ return tomlRunner;
120
+ }
121
+ }
122
+ throw new Error(
123
+ "no Python with tomllib found (needs 3.11+). Install one, or put it on PATH: the release " +
124
+ "contract reads Cargo manifests through it.",
125
+ );
126
+ }
127
+
104
128
  function readToml(file, label) {
105
129
  const script = "import json,pathlib,sys,tomllib; json.dump(tomllib.loads(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')),sys.stdout,default=str)";
106
- const command = process.platform === "win32" ? "py" : "python3";
107
- const args = process.platform === "win32" ? ["-3.11", "-c", script, file] : ["-c", script, file];
108
- const result = spawnSync(command, args, { encoding: "utf8", windowsHide: true });
130
+ const { command, prefix } = resolveTomlRunner();
131
+ const result = spawnSync(command, [...prefix, "-c", script, file], { encoding: "utf8", windowsHide: true });
109
132
  if (result.status !== 0) throw new Error(`${label} is not valid TOML: ${String(result.stderr ?? "").trim()}`);
110
133
  return JSON.parse(result.stdout);
111
134
  }
@@ -44,6 +44,8 @@ if (first === "--version" || first === "-v") {
44
44
  const rest = args.slice(1);
45
45
  if (rest[0] === "cargo") {
46
46
  run("publish-cargo.mjs", rest.slice(1));
47
+ } else if (rest[0] === "swift") {
48
+ run("publish-swift.mjs", rest.slice(1));
47
49
  } else {
48
50
  run("release.mjs", rest.includes("--upload") || rest.includes("--publish") ? rest : [...rest, "--upload"]);
49
51
  }
@@ -120,6 +122,7 @@ Commands:
120
122
  release [--platform mac|win] --tier patch|update Build/package through the signed release lane
121
123
  publish [--platform mac|win] --tier patch|update Release plus R2 upload + RightApps registration
122
124
  publish cargo --crate <name> [--dry-run] Test, inspect, scan, and publish one crates.io package
125
+ publish swift [--scope <s>] [--url <u>] [--dry-run] Test, archive, scan, and publish RightKitSwift to a Swift registry
123
126
  model promote --authority heardright --config <file> [--dry-run]
124
127
  Sign and promote one runtime/model artifact
125
128
  doctor [--platform mac|win] Inspect one app's release config
@@ -0,0 +1,165 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const REQUIRED_ROLES = new Set([
6
+ "license",
7
+ "eula",
8
+ "acceptable_use",
9
+ "product_schedule",
10
+ "privacy_notice",
11
+ "third_party_notices",
12
+ ]);
13
+ const AGREEMENT_ROLES = new Set(["license", "eula", "acceptable_use", "product_schedule"]);
14
+ const SHA256 = /^[a-f0-9]{64}$/i;
15
+ const APP_KEY = /^[a-z][a-z0-9-]{1,63}$/;
16
+ const PLACEHOLDER = /\[(?:COUNSEL|TODO|TBD)(?::|\])|\b(?:TODO|TBD|PLACEHOLDER|NOT READY|RELEASE BLOCKER)\b|⚠\s*verify|^- \[ \]/im;
17
+
18
+ function sha256(bytes) {
19
+ return createHash("sha256").update(bytes).digest("hex");
20
+ }
21
+
22
+ function fail(appName, message) {
23
+ throw new Error(`${appName ?? "app"} legal contract: ${message}`);
24
+ }
25
+
26
+ function within(parent, candidate) {
27
+ const relative = path.relative(parent, candidate);
28
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
29
+ }
30
+
31
+ function resolveContained(parent, relative, appName, label) {
32
+ if (typeof relative !== "string" || !relative.trim() || path.isAbsolute(relative)) {
33
+ fail(appName, `${label} must be a non-empty relative path`);
34
+ }
35
+ const candidate = path.resolve(parent, relative);
36
+ if (!within(parent, candidate)) fail(appName, `${label} path escapes the legal snapshot (path traversal)`);
37
+ return candidate;
38
+ }
39
+
40
+ function readJson(file, appName, label) {
41
+ let value;
42
+ try {
43
+ value = JSON.parse(readFileSync(file, "utf8"));
44
+ } catch (error) {
45
+ fail(appName, `${label} is missing or invalid JSON: ${error.message}`);
46
+ }
47
+ return value;
48
+ }
49
+
50
+ export function assertLegalReleaseContract(root, legal, appName, platform) {
51
+ const appRoot = realpathSync(root);
52
+ if (!legal || typeof legal !== "object" || typeof legal.manifest !== "string") {
53
+ fail(appName, "release config must declare legal.manifest");
54
+ }
55
+ const manifestPath = path.resolve(appRoot, legal.manifest);
56
+ if (!within(appRoot, manifestPath) || !existsSync(manifestPath)) {
57
+ fail(appName, `legal.manifest must exist inside the app root: ${legal.manifest}`);
58
+ }
59
+ const manifestBytes = readFileSync(manifestPath);
60
+ const manifest = readJson(manifestPath, appName, "legal manifest");
61
+ const snapshotRoot = realpathSync(path.dirname(manifestPath));
62
+
63
+ if (manifest.schema !== 1 || manifest.suite !== "right-suite-desktop") {
64
+ fail(appName, "manifest must use Right Suite legal schema 1");
65
+ }
66
+ if (!APP_KEY.test(manifest.appKey ?? "") || (appName && manifest.appKey !== appName)) {
67
+ fail(appName, `manifest appKey must match release app (${manifest.appKey ?? "missing"})`);
68
+ }
69
+ if (manifest.freeWorkerMaximum !== 9 || manifest.paidWorkerMinimum !== 10) {
70
+ fail(appName, "manifest Worker threshold must be free through 9 and paid from 10");
71
+ }
72
+ if (typeof manifest.acceptanceVersion !== "string" || !manifest.acceptanceVersion.trim()) {
73
+ fail(appName, "manifest acceptanceVersion is required");
74
+ }
75
+ if (!Array.isArray(manifest.documents)) fail(appName, "manifest documents must be an array");
76
+
77
+ const seen = new Set();
78
+ const resolvedDocuments = [];
79
+ for (const document of manifest.documents) {
80
+ if (!REQUIRED_ROLES.has(document.role)) fail(appName, `unsupported document role: ${document.role ?? "missing"}`);
81
+ if (seen.has(document.role)) fail(appName, `duplicate document role: ${document.role}`);
82
+ seen.add(document.role);
83
+ if (!SHA256.test(document.sha256 ?? "")) fail(appName, `${document.role} sha256 must be 64 hexadecimal characters`);
84
+ if (AGREEMENT_ROLES.has(document.role) && (document.treatment !== "agree" || document.material !== true)) {
85
+ fail(appName, `${document.role} must be a material agreement`);
86
+ }
87
+ if (document.role === "privacy_notice" && document.treatment !== "acknowledge") {
88
+ fail(appName, "privacy_notice must be acknowledged, not treated as consent/agreement");
89
+ }
90
+ if (document.role === "third_party_notices" && document.treatment !== "notice") {
91
+ fail(appName, "third_party_notices must use notice treatment");
92
+ }
93
+ const file = resolveContained(snapshotRoot, document.path, appName, `${document.role} document`);
94
+ if (!existsSync(file)) fail(appName, `missing ${document.role} document: ${document.path}`);
95
+ const realFile = realpathSync(file);
96
+ if (!within(snapshotRoot, realFile) || !within(appRoot, realFile)) {
97
+ fail(appName, `${document.role} document resolves outside the legal snapshot`);
98
+ }
99
+ const bytes = readFileSync(realFile);
100
+ if (sha256(bytes) !== document.sha256.toLowerCase()) {
101
+ fail(appName, `${document.path} sha256 does not match legal-manifest.json`);
102
+ }
103
+ const text = bytes.toString("utf8");
104
+ if (PLACEHOLDER.test(text)) {
105
+ fail(appName, `${document.path} contains a placeholder or unchecked release blocker`);
106
+ }
107
+ if (document.role === "third_party_notices") {
108
+ if (!/build provenance\s*:/i.test(text) || !/inputs\s*:/i.test(text) || !/generated-by\s*=/i.test(text)) {
109
+ fail(appName, "third-party notices must contain Build provenance, inputs, and generated-by evidence");
110
+ }
111
+ }
112
+ resolvedDocuments.push({ ...document, absolutePath: realFile });
113
+ }
114
+ for (const role of REQUIRED_ROLES) {
115
+ if (!seen.has(role)) fail(appName, `missing required document role: ${role}`);
116
+ }
117
+
118
+ const appendixIds = new Set();
119
+ const appendices = [];
120
+ if (manifest.appendices != null && !Array.isArray(manifest.appendices)) {
121
+ fail(appName, "manifest appendices must be an array");
122
+ }
123
+ for (const appendix of manifest.appendices ?? []) {
124
+ if (typeof appendix.id !== "string" || !appendix.id.trim()) fail(appName, "appendix id is required");
125
+ if (appendixIds.has(appendix.id)) fail(appName, `duplicate appendix id: ${appendix.id}`);
126
+ appendixIds.add(appendix.id);
127
+ if (typeof appendix.title !== "string" || !appendix.title.trim()) fail(appName, `${appendix.id} appendix title is required`);
128
+ if (!SHA256.test(appendix.sha256 ?? "")) fail(appName, `${appendix.id} appendix sha256 is invalid`);
129
+ const file = resolveContained(snapshotRoot, appendix.path, appName, `${appendix.id} appendix`);
130
+ if (!existsSync(file)) fail(appName, `${appendix.id} appendix is missing: ${appendix.path}`);
131
+ const realFile = realpathSync(file);
132
+ if (!within(snapshotRoot, realFile) || !within(appRoot, realFile)) {
133
+ fail(appName, `${appendix.id} appendix resolves outside the legal snapshot`);
134
+ }
135
+ const bytes = readFileSync(realFile);
136
+ if (sha256(bytes) !== appendix.sha256.toLowerCase()) fail(appName, `${appendix.id} appendix sha256 does not match`);
137
+ if (PLACEHOLDER.test(bytes.toString("utf8"))) fail(appName, `${appendix.id} appendix contains a release blocker`);
138
+ appendices.push({ ...appendix, absolutePath: realFile });
139
+ }
140
+
141
+ if (platform === "win") {
142
+ if (typeof legal.tauriConfig !== "string") fail(appName, "Windows legal gate requires legal.tauriConfig");
143
+ const tauriPath = path.resolve(appRoot, legal.tauriConfig);
144
+ if (!within(appRoot, tauriPath) || !existsSync(tauriPath)) fail(appName, `missing Tauri config: ${legal.tauriConfig}`);
145
+ const tauri = readJson(tauriPath, appName, "Tauri config");
146
+ const configured = tauri.bundle?.licenseFile;
147
+ if (typeof configured !== "string" || !configured.trim()) {
148
+ fail(appName, "Windows Tauri bundle.licenseFile must point to the snapshot EULA");
149
+ }
150
+ const configuredPath = path.resolve(path.dirname(tauriPath), configured);
151
+ const eula = resolvedDocuments.find((document) => document.role === "eula");
152
+ if (!eula || path.normalize(configuredPath).toLowerCase() !== path.normalize(eula.absolutePath).toLowerCase()) {
153
+ fail(appName, "Windows Tauri bundle.licenseFile must point to the exact EULA snapshot");
154
+ }
155
+ }
156
+
157
+ return {
158
+ appKey: manifest.appKey,
159
+ acceptanceVersion: manifest.acceptanceVersion,
160
+ manifestPath,
161
+ manifestSha256: sha256(manifestBytes),
162
+ documents: resolvedDocuments,
163
+ appendices,
164
+ };
165
+ }
@@ -0,0 +1,136 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { assertLegalReleaseContract } from "./legal-contract.mjs";
8
+
9
+ function digest(text) {
10
+ return createHash("sha256").update(text).digest("hex");
11
+ }
12
+
13
+ function fixture() {
14
+ const root = mkdtempSync(path.join(os.tmpdir(), "right-legal-contract-"));
15
+ const legalDir = path.join(root, "legal");
16
+ const tauriDir = path.join(root, "src-tauri");
17
+ mkdirSync(legalDir, { recursive: true });
18
+ mkdirSync(tauriDir, { recursive: true });
19
+ const definitions = [
20
+ ["license", "LICENSE.md", "Ownership notice\n"],
21
+ ["eula", "EULA.md", "End user license agreement\n"],
22
+ ["acceptable_use", "ACCEPTABLE_USE.md", "Acceptable use\n"],
23
+ ["product_schedule", "PRODUCT_SCHEDULE.md", "Product schedule\n"],
24
+ ["privacy_notice", "PRIVACY.md", "Privacy notice\n"],
25
+ ["third_party_notices", "THIRD_PARTY_NOTICES.md", "Build provenance: fixture 1.0 windows-x86_64; inputs: pnpm-lock=abc Cargo.lock=def; generated-by=fixture\n"],
26
+ ];
27
+ for (const [, file, content] of definitions) writeFileSync(path.join(legalDir, file), content);
28
+ const manifest = {
29
+ schema: 1,
30
+ suite: "right-suite-desktop",
31
+ appKey: "fixture",
32
+ productName: "Fixture",
33
+ licensor: "Damned Ventures LLC",
34
+ effectiveDate: "2026-07-16",
35
+ acceptanceVersion: "fixture-2026-07-16-v1",
36
+ freeWorkerMaximum: 9,
37
+ paidWorkerMinimum: 10,
38
+ documents: definitions.map(([role, file, content]) => ({
39
+ id: `fixture-${role}`,
40
+ role,
41
+ title: role,
42
+ version: "1.0",
43
+ sha256: digest(content),
44
+ path: file,
45
+ publicUrl: `https://example.test/legal/${role}`,
46
+ treatment: ["privacy_notice"].includes(role) ? "acknowledge" : role === "third_party_notices" ? "notice" : "agree",
47
+ material: !["privacy_notice", "third_party_notices"].includes(role),
48
+ })),
49
+ };
50
+ writeFileSync(path.join(legalDir, "legal-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
51
+ writeFileSync(
52
+ path.join(tauriDir, "tauri.conf.json"),
53
+ `${JSON.stringify({ bundle: { licenseFile: "../legal/EULA.md" } }, null, 2)}\n`,
54
+ );
55
+ return {
56
+ root,
57
+ legal: { manifest: "legal/legal-manifest.json", tauriConfig: "src-tauri/tauri.conf.json" },
58
+ manifest,
59
+ };
60
+ }
61
+
62
+ test("accepts a complete, hash-bound legal snapshot", () => {
63
+ const { root, legal } = fixture();
64
+ const result = assertLegalReleaseContract(root, legal, "fixture", "win");
65
+ assert.equal(result.appKey, "fixture");
66
+ assert.equal(result.documents.length, 6);
67
+ });
68
+
69
+ test("rejects a missing legal config", () => {
70
+ const { root } = fixture();
71
+ assert.throws(() => assertLegalReleaseContract(root, null, "fixture", "win"), /must declare legal\.manifest/i);
72
+ });
73
+
74
+ test("rejects changed bytes and unsafe manifest paths", () => {
75
+ const changed = fixture();
76
+ writeFileSync(path.join(changed.root, "legal", "EULA.md"), "changed without a new manifest\n");
77
+ assert.throws(() => assertLegalReleaseContract(changed.root, changed.legal, "fixture", "win"), /EULA\.md.*sha-?256/i);
78
+
79
+ const traversal = fixture();
80
+ traversal.manifest.documents[0].path = "../outside.md";
81
+ writeFileSync(path.join(traversal.root, "legal", "legal-manifest.json"), `${JSON.stringify(traversal.manifest)}\n`);
82
+ assert.throws(() => assertLegalReleaseContract(traversal.root, traversal.legal, "fixture", "win"), /path.*snapshot|traversal/i);
83
+ });
84
+
85
+ test("rejects placeholders and unresolved checklist markers", () => {
86
+ const { root, legal, manifest } = fixture();
87
+ const eula = "Terms [COUNSEL: decide later]\n- [ ] fill this in\n";
88
+ writeFileSync(path.join(root, "legal", "EULA.md"), eula);
89
+ manifest.documents.find((document) => document.role === "eula").sha256 = digest(eula);
90
+ writeFileSync(path.join(root, "legal", "legal-manifest.json"), `${JSON.stringify(manifest)}\n`);
91
+ assert.throws(() => assertLegalReleaseContract(root, legal, "fixture", "win"), /placeholder|unchecked|release blocker/i);
92
+ });
93
+
94
+ test("rejects an explicitly unresolved release blocker even without a checkbox", () => {
95
+ const { root, legal, manifest } = fixture();
96
+ const notice = "Build provenance: fixture; inputs: lock=abc; generated-by=fixture\nRELEASE BLOCKER: exact model notice is unresolved.\n";
97
+ writeFileSync(path.join(root, "legal", "THIRD_PARTY_NOTICES.md"), notice);
98
+ manifest.documents.find((document) => document.role === "third_party_notices").sha256 = digest(notice);
99
+ writeFileSync(path.join(root, "legal", "legal-manifest.json"), `${JSON.stringify(manifest)}\n`);
100
+ assert.throws(() => assertLegalReleaseContract(root, legal, "fixture", "win"), /release blocker/i);
101
+ });
102
+
103
+ test("requires Windows installer clickthrough to use the exact EULA snapshot", () => {
104
+ const { root, legal } = fixture();
105
+ writeFileSync(path.join(root, "src-tauri", "tauri.conf.json"), JSON.stringify({ bundle: { licenseFile: "../legal/LICENSE.md" } }));
106
+ assert.throws(() => assertLegalReleaseContract(root, legal, "fixture", "win"), /licenseFile.*EULA/i);
107
+ });
108
+
109
+ test("requires third-party build provenance", () => {
110
+ const { root, legal, manifest } = fixture();
111
+ const notice = "Third-party software is included.\n";
112
+ writeFileSync(path.join(root, "legal", "THIRD_PARTY_NOTICES.md"), notice);
113
+ manifest.documents.find((document) => document.role === "third_party_notices").sha256 = digest(notice);
114
+ writeFileSync(path.join(root, "legal", "legal-manifest.json"), `${JSON.stringify(manifest)}\n`);
115
+ assert.throws(() => assertLegalReleaseContract(root, legal, "fixture", "win"), /third-party.*provenance/i);
116
+ });
117
+
118
+ test("manifest hash evidence is the hash of the manifest bytes", () => {
119
+ const { root, legal } = fixture();
120
+ const result = assertLegalReleaseContract(root, legal, "fixture", "mac");
121
+ const bytes = readFileSync(path.join(root, legal.manifest));
122
+ assert.equal(result.manifestSha256, createHash("sha256").update(bytes).digest("hex"));
123
+ });
124
+
125
+ test("hash-binds declared third-party appendices and rejects missing files", () => {
126
+ const { root, legal, manifest } = fixture();
127
+ const appendix = "full dependency license text\n";
128
+ writeFileSync(path.join(root, "legal", "RUST_LICENSES.txt"), appendix);
129
+ manifest.appendices = [{ id: "rust-licenses", title: "Rust licenses", path: "RUST_LICENSES.txt", sha256: digest(appendix) }];
130
+ writeFileSync(path.join(root, "legal", "legal-manifest.json"), `${JSON.stringify(manifest)}\n`);
131
+ assert.equal(assertLegalReleaseContract(root, legal, "fixture", "mac").appendices.length, 1);
132
+
133
+ manifest.appendices[0].path = "missing.txt";
134
+ writeFileSync(path.join(root, "legal", "legal-manifest.json"), `${JSON.stringify(manifest)}\n`);
135
+ assert.throws(() => assertLegalReleaseContract(root, legal, "fixture", "mac"), /appendix.*missing/i);
136
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,11 +14,6 @@
14
14
  "*.py"
15
15
  ],
16
16
  "sideEffects": false,
17
- "scripts": {
18
- "test": "node --test *.test.mjs",
19
- "doctor:all": "node --test right-suite-contract.test.mjs",
20
- "verify:standalone": "node standalone-clone-verify.mjs"
21
- },
22
17
  "publishConfig": {
23
18
  "registry": "https://registry.npmjs.org/",
24
19
  "access": "public"
@@ -28,5 +23,9 @@
28
23
  "url": "git+https://github.com/adrdsouza/claude.git",
29
24
  "directory": "tools/rightkit/packages/release"
30
25
  },
31
- "packageManager": "pnpm@11.12.0"
32
- }
26
+ "scripts": {
27
+ "test": "node --test *.test.mjs",
28
+ "doctor:all": "node --test right-suite-contract.test.mjs",
29
+ "verify:standalone": "node standalone-clone-verify.mjs"
30
+ }
31
+ }
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // Publish RightKitSwift to a Swift package registry, gated exactly like `publish cargo`:
3
+ // secret scan -> swift test -> release build -> deterministic source archive + hash ->
4
+ // registry dry-run -> publish. Canonical source stays under tools/rightkit/swift; the
5
+ // registry package is an output only. No Git distribution repo, no path/Git dependency.
6
+ //
7
+ // swift-tools is macOS/Linux only, so the plan builder, arg parser, and version check are
8
+ // pure and unit-tested on any OS (publish-swift.test.mjs); the swift steps run where swift exists.
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { createHash } from "node:crypto";
12
+ import { spawnSync } from "node:child_process";
13
+ import { scanCrateForSecrets } from "./publish-cargo.mjs";
14
+
15
+ const IDENTIFIER = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/i;
16
+
17
+ export function parseSwiftPublishArgs(argv, env = process.env) {
18
+ const allowed = new Set(["--scope", "--url", "--dry-run", "--allow-dirty"]);
19
+ let scope = env.RIGHTKIT_SWIFT_REGISTRY_SCOPE ?? "rightsuite";
20
+ let url = env.RIGHTKIT_SWIFT_REGISTRY_URL ?? "";
21
+ let dryRun = false;
22
+ let allowDirty = false;
23
+ for (let index = 0; index < argv.length; index += 1) {
24
+ const arg = argv[index];
25
+ if (!allowed.has(arg)) throw new Error(`unexpected argument: ${arg}`);
26
+ if (arg === "--dry-run") dryRun = true;
27
+ else if (arg === "--allow-dirty") allowDirty = true;
28
+ else {
29
+ const value = argv[(index += 1)];
30
+ if (value === undefined) throw new Error(`${arg} needs a value`);
31
+ if (arg === "--scope") scope = value;
32
+ if (arg === "--url") url = value;
33
+ }
34
+ }
35
+ if (!IDENTIFIER.test(scope)) throw new Error(`invalid registry scope: ${scope}`);
36
+ if (allowDirty && !dryRun) throw new Error("--allow-dirty is only valid with --dry-run");
37
+ if (!dryRun && !url) throw new Error("registry publish needs --url or RIGHTKIT_SWIFT_REGISTRY_URL");
38
+ return { scope, url, dryRun, allowDirty };
39
+ }
40
+
41
+ // The VERSION file is the single source of truth; Package.swift must name the package and the
42
+ // version must be a clean semver so the registry archive is reproducible.
43
+ export function readPackageIdentity(packageDir) {
44
+ const manifest = path.join(packageDir, "Package.swift");
45
+ if (!fs.existsSync(manifest)) throw new Error(`Package.swift not found: ${manifest}`);
46
+ const versionFile = path.join(packageDir, "VERSION");
47
+ if (!fs.existsSync(versionFile)) throw new Error(`VERSION not found: ${versionFile}`);
48
+ const version = fs.readFileSync(versionFile, "utf8").trim();
49
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
50
+ throw new Error(`VERSION is not clean semver: "${version}"`);
51
+ }
52
+ const nameMatch = fs.readFileSync(manifest, "utf8").match(/name:\s*"([^"]+)"/);
53
+ if (!nameMatch) throw new Error("could not read package name from Package.swift");
54
+ return { name: nameMatch[1], version };
55
+ }
56
+
57
+ export function buildSwiftPublishPlan({ packageDir, scope, name, version, url, dryRun, allowDirty = false }) {
58
+ const packageId = `${scope}.${name}`;
59
+ const archive = path.join(packageDir, ".build", `${name}-${version}.zip`);
60
+ const plan = [
61
+ { label: "secret scan", internal: "secret-scan" },
62
+ { label: "tests", command: "swift", args: ["test", "--package-path", packageDir] },
63
+ { label: "release build", command: "swift", args: ["build", "-c", "release", "--package-path", packageDir] },
64
+ // Deterministic source archive; its bytes are what the registry stores, so hash it for the log.
65
+ { label: "source archive", command: "swift", args: ["package", "--package-path", packageDir, "archive-source", "-o", archive] },
66
+ { label: "archive hash", internal: "archive-hash", archive },
67
+ ];
68
+ if (dryRun) {
69
+ plan.push({ label: "registry dry-run (validate only)", internal: "dry-run-note", packageId, version });
70
+ return plan;
71
+ }
72
+ plan.push({
73
+ label: "registry publish",
74
+ command: "swift",
75
+ args: ["package-registry", "publish", packageId, version, "--url", url, "--package-path", packageDir],
76
+ });
77
+ return plan;
78
+ }
79
+
80
+ export function runSwiftPublish(options, execute = spawnSync) {
81
+ const packageDir = options.packageDir;
82
+ const { name, version } = readPackageIdentity(packageDir);
83
+ const plan = buildSwiftPublishPlan({ ...options, packageDir, name, version });
84
+
85
+ for (const step of plan) {
86
+ process.stdout.write(`[right-release] swift ${step.label}\n`);
87
+ if (step.internal === "secret-scan") {
88
+ const violations = scanCrateForSecrets(packageDir);
89
+ if (violations.length) throw new Error(`secret scan rejected: ${violations.join(", ")}`);
90
+ continue;
91
+ }
92
+ if (step.internal === "dry-run-note") {
93
+ process.stdout.write(`[right-release] dry-run OK: ${step.packageId} ${step.version} (not published)\n`);
94
+ continue;
95
+ }
96
+ if (step.internal === "archive-hash") {
97
+ const bytes = fs.readFileSync(step.archive);
98
+ process.stdout.write(`sha256 ${createHash("sha256").update(bytes).digest("hex")} ${path.basename(step.archive)}\n`);
99
+ continue;
100
+ }
101
+ const result = execute(step.command, step.args, { cwd: packageDir, encoding: "utf8", windowsHide: true, stdio: "inherit" });
102
+ if (result.error) {
103
+ if (result.error.code === "ENOENT") {
104
+ throw new Error(`${step.command} not found — run publish swift on macOS/Linux with the Swift toolchain installed`);
105
+ }
106
+ throw result.error;
107
+ }
108
+ if (result.status !== 0) throw new Error(`${step.label} failed with exit code ${result.status}`);
109
+ }
110
+ return { name, version, published: !options.dryRun };
111
+ }
112
+
113
+ if (process.argv[1]?.endsWith("publish-swift.mjs")) {
114
+ try {
115
+ const options = parseSwiftPublishArgs(process.argv.slice(2));
116
+ const packageDir = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/(\w:)/, "$1")), "../../swift/RightKitSwift");
117
+ runSwiftPublish({ ...options, packageDir });
118
+ } catch (error) {
119
+ process.stderr.write(`${error.message}\n`);
120
+ process.exit(1);
121
+ }
122
+ }
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import test from "node:test";
5
+ import { parseSwiftPublishArgs, buildSwiftPublishPlan, readPackageIdentity } from "./publish-swift.mjs";
6
+
7
+ const here = path.dirname(fileURLToPath(import.meta.url));
8
+ const packageDir = path.resolve(here, "../../swift/RightKitSwift");
9
+
10
+ test("dry-run needs no url; publish requires one", () => {
11
+ assert.deepEqual(parseSwiftPublishArgs(["--dry-run"], {}), {
12
+ scope: "rightsuite", url: "", dryRun: true, allowDirty: false,
13
+ });
14
+ assert.throws(() => parseSwiftPublishArgs([], {}), /needs --url/);
15
+ assert.equal(parseSwiftPublishArgs(["--url", "https://reg.local"], {}).url, "https://reg.local");
16
+ });
17
+
18
+ test("env supplies scope and url; --allow-dirty is dry-run only", () => {
19
+ const parsed = parseSwiftPublishArgs([], { RIGHTKIT_SWIFT_REGISTRY_URL: "https://e", RIGHTKIT_SWIFT_REGISTRY_SCOPE: "acme" });
20
+ assert.deepEqual(parsed, { scope: "acme", url: "https://e", dryRun: false, allowDirty: false });
21
+ assert.throws(() => parseSwiftPublishArgs(["--allow-dirty"], { RIGHTKIT_SWIFT_REGISTRY_URL: "https://e" }), /only valid with --dry-run/);
22
+ assert.throws(() => parseSwiftPublishArgs(["--bogus"], {}), /unexpected argument/);
23
+ });
24
+
25
+ test("reads the real package identity from VERSION + Package.swift", () => {
26
+ const { name, version } = readPackageIdentity(packageDir);
27
+ assert.equal(name, "RightKitSwift");
28
+ assert.match(version, /^\d+\.\d+\.\d+/);
29
+ });
30
+
31
+ test("dry-run plan stops before publish; real plan ends with publish", () => {
32
+ const base = { packageDir, scope: "rightsuite", name: "RightKitSwift", version: "0.1.0", url: "https://reg.local" };
33
+ const dry = buildSwiftPublishPlan({ ...base, dryRun: true });
34
+ assert.equal(dry[0].internal, "secret-scan");
35
+ assert.ok(dry.some((s) => s.label === "tests" && s.command === "swift"));
36
+ assert.ok(!dry.some((s) => s.label === "registry publish"), "dry-run must not publish");
37
+
38
+ const real = buildSwiftPublishPlan({ ...base, dryRun: false });
39
+ const publish = real.at(-1);
40
+ assert.equal(publish.label, "registry publish");
41
+ assert.deepEqual(publish.args, [
42
+ "package-registry", "publish", "rightsuite.RightKitSwift", "0.1.0", "--url", "https://reg.local", "--package-path", packageDir,
43
+ ]);
44
+ });
@@ -0,0 +1,61 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function validateQaBackdoorContract(root, packageScripts = {}) {
5
+ if (!existsSync(path.join(root, "right-qa.config.mjs"))) return [];
6
+
7
+ const issues = [];
8
+ const cargoPath = path.join(root, "src-tauri", "Cargo.toml");
9
+ if (!existsSync(cargoPath)) return ["right-qa.config.mjs requires src-tauri/Cargo.toml"];
10
+
11
+ const cargoToml = readFileSync(cargoPath, "utf8");
12
+ const dependency = cargoToml.match(/tauri-plugin-wdio-webdriver\s*=\s*\{([^}]*)\}/s)?.[1] ?? "";
13
+ if (!dependency) issues.push("Cargo.toml must declare tauri-plugin-wdio-webdriver");
14
+ else if (!/optional\s*=\s*true/.test(dependency)) issues.push("tauri-plugin-wdio-webdriver must be optional");
15
+
16
+ const qaFeature = cargoToml.match(/qa-native\s*=\s*\[([^\]]*)\]/s)?.[1] ?? "";
17
+ if (!/dep:tauri-plugin-wdio-webdriver/.test(qaFeature)) {
18
+ issues.push("qa-native must enable dep:tauri-plugin-wdio-webdriver");
19
+ }
20
+
21
+ const rustSource = readRustSources(path.join(root, "src-tauri", "src")).replace(/\s+/g, " ");
22
+ if (!/cfg\s*\(\s*all\s*\(\s*not\s*\(\s*debug_assertions\s*\)\s*,\s*feature\s*=\s*"qa-native"\s*\)\s*\)/.test(rustSource)
23
+ || !/compile_error!/.test(rustSource)) {
24
+ issues.push("Rust must compile_error when qa-native is enabled outside debug builds");
25
+ }
26
+ if (!/cfg\s*\(\s*all\s*\(\s*debug_assertions\s*,\s*feature\s*=\s*"qa-native"\s*\)\s*\)/.test(rustSource)
27
+ || !/tauri_plugin_wdio_webdriver::init/.test(rustSource)) {
28
+ issues.push("WebDriver registration must require debug_assertions and qa-native");
29
+ }
30
+ if (!/RIGHTKIT_QA_NATIVE/.test(rustSource)) {
31
+ issues.push("WebDriver registration must also require RIGHTKIT_QA_NATIVE");
32
+ }
33
+
34
+ for (const [name, command] of Object.entries(packageScripts)) {
35
+ if (!/qa-native|RIGHTKIT_QA_NATIVE/.test(command)) continue;
36
+ if (/^(qa|test|dev)(:|$)/.test(name)) continue;
37
+ if (/(release|publish|build|dmg|installer|package)/i.test(name) || /\btauri\s+build\b/.test(command)) {
38
+ issues.push(`package script ${name} must not enable native QA`);
39
+ }
40
+ }
41
+ return issues;
42
+ }
43
+
44
+ export function assertQaBackdoorContract(root, packageScripts = {}) {
45
+ const issues = validateQaBackdoorContract(root, packageScripts);
46
+ if (issues.length) throw new Error(`production QA backdoor contract failed:\n- ${issues.join("\n- ")}`);
47
+ }
48
+
49
+ function readRustSources(root) {
50
+ if (!existsSync(root)) return "";
51
+ const files = [];
52
+ const visit = (dir) => {
53
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
54
+ const absolute = path.join(dir, entry.name);
55
+ if (entry.isDirectory()) visit(absolute);
56
+ else if (entry.isFile() && entry.name.endsWith(".rs")) files.push(absolute);
57
+ }
58
+ };
59
+ visit(root);
60
+ return files.sort().map((file) => readFileSync(file, "utf8")).join("\n");
61
+ }
@@ -0,0 +1,47 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { validateQaBackdoorContract } from "./qa-contract.mjs";
7
+
8
+ function fixture({ optional = true, releaseScript = "right-release release --tier patch" } = {}) {
9
+ const root = mkdtempSync(path.join(os.tmpdir(), "right-release-qa-"));
10
+ mkdirSync(path.join(root, "src-tauri", "src"), { recursive: true });
11
+ writeFileSync(path.join(root, "right-qa.config.mjs"), "export default {};\n");
12
+ writeFileSync(path.join(root, "src-tauri", "Cargo.toml"), `
13
+ [features]
14
+ qa-native = ["dep:tauri-plugin-wdio-webdriver"]
15
+ [dependencies]
16
+ tauri-plugin-wdio-webdriver = { version = "1.2.0", optional = ${optional} }
17
+ `);
18
+ writeFileSync(path.join(root, "src-tauri", "src", "lib.rs"), `
19
+ #[cfg(all(not(debug_assertions), feature = "qa-native"))]
20
+ compile_error!("qa-native must never be enabled in release builds");
21
+ #[cfg(all(debug_assertions, feature = "qa-native"))]
22
+ fn qa_enabled() -> bool { std::env::var("RIGHTKIT_QA_NATIVE").as_deref() == Ok("1") }
23
+ #[cfg(all(debug_assertions, feature = "qa-native"))]
24
+ fn add_plugin() { tauri_plugin_wdio_webdriver::init(); }
25
+ `);
26
+ return { root, scripts: { "qa:native": "tauri dev --features qa-native", "release:patch:win": releaseScript } };
27
+ }
28
+
29
+ test("accepts a debug-only, runtime-gated native QA surface", () => {
30
+ const value = fixture();
31
+ assert.deepEqual(validateQaBackdoorContract(value.root, value.scripts), []);
32
+ });
33
+
34
+ test("rejects a non-optional WebDriver plugin", () => {
35
+ const value = fixture({ optional: false });
36
+ assert.match(validateQaBackdoorContract(value.root, value.scripts).join("\n"), /must be optional/);
37
+ });
38
+
39
+ test("rejects release scripts that enable native QA", () => {
40
+ const value = fixture({ releaseScript: "tauri build --features qa-native" });
41
+ assert.match(validateQaBackdoorContract(value.root, value.scripts).join("\n"), /release:patch:win/);
42
+ });
43
+
44
+ test("does not impose native QA on an app before its config lands", () => {
45
+ const root = mkdtempSync(path.join(os.tmpdir(), "right-release-no-qa-"));
46
+ assert.deepEqual(validateQaBackdoorContract(root, {}), []);
47
+ });
package/release.mjs CHANGED
@@ -5,6 +5,8 @@ import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { spawn, spawnSync } from "node:child_process";
7
7
  import { validateRightKitCargoContract } from "./cargo-contract.mjs";
8
+ import { assertQaBackdoorContract } from "./qa-contract.mjs";
9
+ import { assertLegalReleaseContract } from "./legal-contract.mjs";
8
10
 
9
11
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
10
12
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -66,6 +68,9 @@ await validateRightKitPackageContract(root, config.app);
66
68
  validateRightKitCargoContract(root, RIGHTKIT_VERSIONS.cargo, config.app ?? path.basename(root));
67
69
  const target = config.targets?.[opts.platform];
68
70
  if (!target) fail(`${config.app ?? "app"} has no ${opts.platform} release target`);
71
+ const legalContract = config.legal
72
+ ? assertLegalReleaseContract(root, config.legal, config.app ?? path.basename(root), opts.platform)
73
+ : null;
69
74
  if (target.signed !== true) {
70
75
  fail(`${config.app ?? "app"} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
71
76
  }
@@ -82,6 +87,11 @@ if (opts.doctor) {
82
87
  console.log(`packageManager: ${config.packageManager}`);
83
88
  console.log(`workdir: ${workdir}`);
84
89
  console.log(`hardeningscan: ${HARDENING_SCAN}`);
90
+ if (legalContract) {
91
+ console.log(`legal: ${legalContract.manifestPath}`);
92
+ console.log(`legalAcceptance: ${legalContract.acceptanceVersion}`);
93
+ console.log(`legalManifestSha256: ${legalContract.manifestSha256}`);
94
+ }
85
95
  if (target.sign?.files?.length) console.log(`sign: ${target.sign.files.join(", ")}`);
86
96
  if (target.publishBlocked) console.log(`publishBlocked: ${target.publishBlocked}`);
87
97
  process.exit(0);
@@ -141,6 +151,8 @@ if (opts.upload && !target.publish) {
141
151
  }
142
152
  }
143
153
 
154
+ sweepStaleRustArtifacts();
155
+
144
156
  console.log(`right-release: done (${Date.now() - started}ms)`);
145
157
  releaseLock.release();
146
158
 
@@ -160,6 +172,32 @@ async function mustExist(file, message) {
160
172
  await access(file).catch(() => fail(message));
161
173
  }
162
174
 
175
+ // Post-release target/ hygiene: `cargo sweep --installed` deletes artifacts left
176
+ // by toolchains rustup no longer has (each update orphans a multi-GB pile per
177
+ // app) and never touches anything the current toolchains produced. Runs after
178
+ // publish so it cannot race an artifact, and is best-effort: a missing
179
+ // cargo-sweep or a sweep failure must never fail a release.
180
+ function sweepStaleRustArtifacts() {
181
+ const candidates = [...new Set([root, workdir].flatMap((base) => [base, path.join(base, "src-tauri")]))];
182
+ const projects = candidates.filter((dir) => existsSync(path.join(dir, "target")));
183
+ for (const dir of projects) {
184
+ if (opts.dryRun) {
185
+ console.log(`dry-run: cargo sweep --installed ${dir}`);
186
+ continue;
187
+ }
188
+ try {
189
+ const result = spawnSync("cargo", ["sweep", "--installed", dir], { stdio: "inherit" });
190
+ if (result.status !== 0) {
191
+ console.log("right-release: stale-artifact sweep skipped (install with: cargo install cargo-sweep)");
192
+ return;
193
+ }
194
+ } catch {
195
+ console.log("right-release: stale-artifact sweep skipped (install with: cargo install cargo-sweep)");
196
+ return;
197
+ }
198
+ }
199
+ }
200
+
163
201
  async function runInstall(pm, cwd) {
164
202
  if (pm === "pnpm") await run("pnpm", ["install", "--frozen-lockfile"], cwd);
165
203
  else if (pm === "npm") await run("npm", ["ci"], cwd);
@@ -180,6 +218,7 @@ async function validateRightKitPackageContract(root, appName) {
180
218
  const packageJsonPath = path.join(root, "package.json");
181
219
  const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
182
220
  const scripts = pkg.scripts ?? {};
221
+ assertQaBackdoorContract(root, scripts);
183
222
  const workflowDir = path.join(root, ".github", "workflows");
184
223
  if (existsSync(workflowDir)) {
185
224
  const workflows = await readdir(workflowDir);
package/release.test.mjs CHANGED
@@ -264,6 +264,21 @@ test("Windows update re-signs the updater artifact after Azure code signing", ()
264
264
  assert.ok(publishAt > updaterAt, result.stdout);
265
265
  });
266
266
 
267
+ test("release sweeps stale rust artifacts when a target dir exists", () => {
268
+ const config = fixture();
269
+ const dir = path.dirname(config);
270
+ mkdirSync(path.join(dir, "src-tauri", "target"), { recursive: true });
271
+ const result = run(config, "--tier=patch");
272
+ assert.equal(result.status, 0, result.stderr);
273
+ assert.match(result.stdout, /dry-run: cargo sweep --installed .*src-tauri/);
274
+ });
275
+
276
+ test("release skips the sweep when no target dir exists", () => {
277
+ const result = run(fixture(), "--tier=patch");
278
+ assert.equal(result.status, 0, result.stderr);
279
+ assert.doesNotMatch(result.stdout, /cargo sweep/);
280
+ });
281
+
267
282
  test("release lock is single-flight and is cleaned after success", () => {
268
283
  const { dir, config } = lockFixture();
269
284
  const lockPath = path.join(dir, ".cache", "right-release", "mac.lock.json");
@@ -10,8 +10,13 @@ import {
10
10
  assertPublishedRightKitCargoDependencies,
11
11
  validateRightKitCargoContract,
12
12
  } from "./cargo-contract.mjs";
13
+ import { assertAsrAdapterPair } from "./asr-artifact-adoption.mjs";
13
14
 
14
15
  const workspace = path.resolve(new URL("../../../..", import.meta.url).pathname.replace(/^\/(\w:)/, "$1"));
16
+ // The Right Suite web layer's repo + filesystem path is `rightsites` post the 2026-07-15 naming lock;
17
+ // a not-yet-renamed checkout may still have `rightapps`. Resolve whichever exists so the contract
18
+ // passes on both. (The runtime service namespace stays `rightapps` — that is deliberately untouched.)
19
+ const siteRepoDir = existsSync(path.join(workspace, "rightsites")) ? "rightsites" : "rightapps";
15
20
  const versionsPath = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/(\w:)/, "$1")), "rightkit-versions.json");
16
21
  const versions = JSON.parse(readFileSync(versionsPath, "utf8"));
17
22
  const isolatedCargoHome = mkdtempSync(path.join(tmpdir(), "rightkit-cargo-home-"));
@@ -21,7 +26,9 @@ const apps = [
21
26
  { key: "viewright", root: "viewright", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/build-mac-notarized.sh"] },
22
27
  { key: "scraperight", root: "scraperight", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["package.sh"] },
23
28
  { key: "heardright", root: "heardright/tauri-app-next", repoRoot: "heardright", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs", "scripts/publish-release.mjs"] },
24
- { key: "mailright", root: "mailright", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/build-mac.sh"] },
29
+ // mac-dmg.mjs is the release file, as in HeardRight/CodeRight. build-mac.sh only delegates to it,
30
+ // so asserting the mirror call against the wrapper failed a pipeline that does mirror.
31
+ { key: "mailright", root: "mailright", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs"] },
25
32
  { key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs"] },
26
33
  ];
27
34
 
@@ -310,7 +317,7 @@ test("Cargo version contract rejects staged versions that mismatch canonical man
310
317
  test("RightKit exposes one current version manifest", () => {
311
318
  assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
312
319
  assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
313
- assert.equal(versions.npm["@rightkit/release"], "0.2.21");
320
+ assert.equal(versions.npm["@rightkit/release"], "0.2.22");
314
321
  assert.equal(versions.npm["@rightkit/license"], "0.1.5");
315
322
  assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
316
323
  assert.equal(versions.npm["@rightkit/tauri"], "0.1.0");
@@ -329,7 +336,7 @@ test("license v2 public vector is identical at every portable consumer boundary"
329
336
  );
330
337
  for (const relativePath of [
331
338
  "tools/rightkit/packages/license/test-vectors/license-v2.json",
332
- "rightapps/packages/api/src/licensing/test-vectors/license-v2.json",
339
+ `${siteRepoDir}/packages/api/src/licensing/test-vectors/license-v2.json`,
333
340
  "scraperight/tests/fixtures/license-v2.json",
334
341
  ]) {
335
342
  assert.equal(
@@ -450,11 +457,17 @@ for (const app of apps) {
450
457
  });
451
458
  }
452
459
 
460
+ test("HeardRight and ScrapeRight expose one locked ASR promotion adapter contract", async () => {
461
+ const heard = (await import(`${pathToFileURL(path.join(workspace, "heardright/tauri-app-next/right-release.config.mjs"))}?asr=${Date.now()}`)).default;
462
+ const scrape = (await import(`${pathToFileURL(path.join(workspace, "scraperight/right-release.config.mjs"))}?asr=${Date.now()}`)).default;
463
+ assert.equal(assertAsrAdapterPair(heard.runtimeArtifacts?.asr, scrape.runtimeArtifacts?.asr), true);
464
+ });
465
+
453
466
  function findFiles(root, filename) {
454
467
  const found = [];
455
468
  const visit = (dir) => {
456
469
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
457
- if (entry.isDirectory() && [".cache", ".git", ".claude", ".worktrees", "node_modules", "target", "vendor"].includes(entry.name)) continue;
470
+ if (entry.isDirectory() && [".audit", ".cache", ".git", ".claude", ".worktrees", "node_modules", "target", "vendor"].includes(entry.name)) continue;
458
471
  const full = path.join(dir, entry.name);
459
472
  if (entry.isDirectory()) visit(full);
460
473
  else if (entry.isFile() && entry.name === filename) found.push(full);
@@ -488,7 +501,7 @@ test("Right Suite has no hosted workflow files", () => {
488
501
 
489
502
  test("RightApps brand hosts expose app-keyed update manifest proxies", () => {
490
503
  for (const app of apps) {
491
- const siteRoot = path.join(workspace, "rightapps", app.key);
504
+ const siteRoot = path.join(workspace, siteRepoDir, app.key);
492
505
  for (const route of ["patches", "latest"]) {
493
506
  const routeFile = path.join(siteRoot, "src", "routes", "releases", `${route}.json`, "index.ts");
494
507
  const source = readFileSync(routeFile, "utf8");
@@ -4,14 +4,23 @@
4
4
  "npm": {
5
5
  "@rightkit/license": "0.1.5",
6
6
  "@rightkit/logs": "0.1.3",
7
- "@rightkit/release": "0.2.21",
7
+ "@rightkit/platform-ui": "0.1.0",
8
+ "@rightkit/qa": "0.1.0",
9
+ "@rightkit/release": "0.2.22",
8
10
  "@rightkit/tauri": "0.1.0",
9
11
  "@rightkit/updates": "0.2.3"
10
12
  },
11
13
  "cargo": {
12
14
  "rightkit-license": "0.1.2",
13
15
  "rightkit-logs": "0.1.0",
16
+ "rightkit-process": "0.1.0",
14
17
  "rightkit-tauri": "0.1.0"
15
18
  },
16
- "stagedCargo": {}
19
+ "stagedCargo": {},
20
+ "swift": {
21
+ "rightkit-swift": {
22
+ "url": "https://github.com/bogusyogi/rightkit-swift.git",
23
+ "version": "0.1.0"
24
+ }
25
+ }
17
26
  }
@@ -32,8 +32,14 @@ function run(command, args, cwd) {
32
32
  function runPnpm(args, cwd, command) {
33
33
  if (process.platform !== "win32" || command !== "pnpm") return run(command, args, cwd);
34
34
  for (const entry of String(process.env.PATH ?? "").split(path.delimiter)) {
35
- const cli = path.join(entry.replace(/^"|"$/g, ""), "node_modules", "pnpm", "bin", "pnpm.mjs");
35
+ const directory = entry.replace(/^"|"$/g, "");
36
+ const cli = path.join(directory, "node_modules", "pnpm", "bin", "pnpm.mjs");
36
37
  if (existsSync(cli)) return run(process.execPath, [cli, ...args], cwd);
38
+ const cmdShim = path.join(directory, "pnpm.cmd");
39
+ if (existsSync(cmdShim)) {
40
+ const target = readFileSync(cmdShim, "utf8").match(/@node\s+"([^"]+pnpm\.(?:c|m)?js)"/i)?.[1];
41
+ if (target && existsSync(target)) return run(process.execPath, [target, ...args], cwd);
42
+ }
37
43
  }
38
44
  throw new Error("pnpm installation could not be resolved from PATH");
39
45
  }