@rightkit/release 0.2.20 → 0.2.22
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/asr-artifact-adoption.mjs +68 -0
- package/asr-artifact-adoption.test.mjs +122 -0
- package/cargo-contract.mjs +26 -3
- package/cli/right-release.mjs +12 -0
- package/model-promote.mjs +262 -0
- package/model-promote.test.mjs +240 -0
- package/package.json +8 -6
- package/publish-swift.mjs +122 -0
- package/publish-swift.test.mjs +44 -0
- package/publish-update.mjs +7 -12
- package/qa-contract.mjs +61 -0
- package/qa-contract.test.mjs +47 -0
- package/release.mjs +30 -0
- package/release.test.mjs +15 -0
- package/right-suite-contract.test.mjs +17 -4
- package/rightapps-register.mjs +31 -0
- package/rightapps-register.test.mjs +28 -0
- package/rightkit-versions.json +11 -2
- package/runtime-artifact-manifest.mjs +247 -0
- package/runtime-artifact-manifest.test.mjs +128 -0
- package/standalone-clone-verify.mjs +131 -0
- package/standalone-clone-verify.test.mjs +76 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash, generateKeyPairSync } from "node:crypto";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
RUNTIME_ARTIFACT_TRUSTED_KEY_ID,
|
|
12
|
+
defaultWindowsRuntimeArtifactSigningKeyFile,
|
|
13
|
+
loadRuntimeArtifactSigningKey,
|
|
14
|
+
promoteRuntimeArtifact,
|
|
15
|
+
runtimeArtifactUploadEnv,
|
|
16
|
+
} from "./model-promote.mjs";
|
|
17
|
+
|
|
18
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
19
|
+
const artifactDigest = "a".repeat(64);
|
|
20
|
+
const evidenceDigest = "b".repeat(64);
|
|
21
|
+
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
function request(overrides = {}) {
|
|
24
|
+
return {
|
|
25
|
+
authority: "heardright",
|
|
26
|
+
privateKey,
|
|
27
|
+
keyId: RUNTIME_ARTIFACT_TRUSTED_KEY_ID,
|
|
28
|
+
artifactFile: "C:/fixtures/parakeet.onnx",
|
|
29
|
+
evidenceFile: "C:/fixtures/eval.json",
|
|
30
|
+
config: {
|
|
31
|
+
artifact: {
|
|
32
|
+
filename: "parakeet.onnx",
|
|
33
|
+
sha256: artifactDigest,
|
|
34
|
+
sizeBytes: 42,
|
|
35
|
+
},
|
|
36
|
+
evidence: { sha256: evidenceDigest },
|
|
37
|
+
manifest: {
|
|
38
|
+
artifactKind: "asr-model",
|
|
39
|
+
entitlement: { appKey: "scraperight", tier: "pro" },
|
|
40
|
+
distribution: { delivery: "private-r2", bucket: "rightapps-updates" },
|
|
41
|
+
target: { os: "windows", arch: "x86_64" },
|
|
42
|
+
versions: {
|
|
43
|
+
runtime: "rightkit-asr-0.1.0",
|
|
44
|
+
model: "parakeet-tdt-0.6b-v3",
|
|
45
|
+
tokenizer: "sentencepiece-2026-07-14",
|
|
46
|
+
preprocessing: "rightkit-asr-0.1.0",
|
|
47
|
+
license: "cc-by-4.0",
|
|
48
|
+
provenance: "heardright-approved-2026-07-14",
|
|
49
|
+
},
|
|
50
|
+
provenance: {
|
|
51
|
+
source: "https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3",
|
|
52
|
+
sourceRevision: "0123456789abcdef",
|
|
53
|
+
licenseId: "CC-BY-4.0",
|
|
54
|
+
noticeSha256: "c".repeat(64),
|
|
55
|
+
},
|
|
56
|
+
promotion: {
|
|
57
|
+
promotionId: "hr-asr-2026-07-14-001",
|
|
58
|
+
promotedAt: "2026-07-14T12:00:00.000Z",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function boundaries(events) {
|
|
67
|
+
return {
|
|
68
|
+
metadata: async (file) => file.endsWith("eval.json")
|
|
69
|
+
? { sha256: evidenceDigest, sizeBytes: 10 }
|
|
70
|
+
: { sha256: artifactDigest, sizeBytes: 42 },
|
|
71
|
+
upload: async ({ purpose }) => { events.push(`upload:${purpose}`); },
|
|
72
|
+
register: async () => { events.push("register"); },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test("uploads immutable object, registers signed envelope, then replaces stable pointer", async () => {
|
|
77
|
+
const events = [];
|
|
78
|
+
const result = await promoteRuntimeArtifact(request(), boundaries(events));
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(events, ["upload:object", "register", "upload:pointer"]);
|
|
81
|
+
assert.equal(result.envelope.signature.keyId, RUNTIME_ARTIFACT_TRUSTED_KEY_ID);
|
|
82
|
+
assert.equal(result.envelope.manifest.object.sha256, artifactDigest);
|
|
83
|
+
assert.equal(result.envelope.manifest.promotion.evidenceSha256, evidenceDigest);
|
|
84
|
+
assert.match(result.envelope.manifest.pointerKey, /\/current\/manifest\.json$/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("dry-run validates and signs without invoking process or network boundaries", async () => {
|
|
88
|
+
const events = [];
|
|
89
|
+
const result = await promoteRuntimeArtifact(request({ dryRun: true }), boundaries(events));
|
|
90
|
+
assert.deepEqual(events, []);
|
|
91
|
+
assert.deepEqual(result.steps, ["upload immutable object", "register signed envelope", "replace stable pointer"]);
|
|
92
|
+
assert.equal(result.envelope.signature.algorithm, "Ed25519");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("never registers or publishes a pointer after immutable upload failure", async () => {
|
|
96
|
+
const events = [];
|
|
97
|
+
const deps = boundaries(events);
|
|
98
|
+
deps.upload = async ({ purpose }) => {
|
|
99
|
+
events.push(`upload:${purpose}`);
|
|
100
|
+
throw new Error("R2 failed");
|
|
101
|
+
};
|
|
102
|
+
await assert.rejects(promoteRuntimeArtifact(request(), deps), /R2 failed/);
|
|
103
|
+
assert.deepEqual(events, ["upload:object"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("never publishes a pointer when RightApps registration fails", async () => {
|
|
107
|
+
const events = [];
|
|
108
|
+
const deps = boundaries(events);
|
|
109
|
+
deps.register = async () => {
|
|
110
|
+
events.push("register");
|
|
111
|
+
throw new Error("registration failed");
|
|
112
|
+
};
|
|
113
|
+
await assert.rejects(promoteRuntimeArtifact(request(), deps), /registration failed/);
|
|
114
|
+
assert.deepEqual(events, ["upload:object", "register"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("fails before mutation on digest, authority, key-id, or lane contradiction", async () => {
|
|
118
|
+
const badRequests = [
|
|
119
|
+
request({ authority: "scraperight" }),
|
|
120
|
+
request({ keyId: "untrusted-key" }),
|
|
121
|
+
request({ config: { ...request().config, artifact: { ...request().config.artifact, sha256: "d".repeat(64) } } }),
|
|
122
|
+
request({ config: {
|
|
123
|
+
...request().config,
|
|
124
|
+
manifest: {
|
|
125
|
+
...request().config.manifest,
|
|
126
|
+
entitlement: { appKey: "scraperight", tier: "public" },
|
|
127
|
+
distribution: { delivery: "public-r2", bucket: "rightapps-downloads" },
|
|
128
|
+
},
|
|
129
|
+
} }),
|
|
130
|
+
request({ config: {
|
|
131
|
+
...request().config,
|
|
132
|
+
manifest: {
|
|
133
|
+
...request().config.manifest,
|
|
134
|
+
promotion: {
|
|
135
|
+
...request().config.manifest.promotion,
|
|
136
|
+
authorityAppKey: "scraperight",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
} }),
|
|
140
|
+
];
|
|
141
|
+
for (const input of badRequests) {
|
|
142
|
+
const events = [];
|
|
143
|
+
await assert.rejects(promoteRuntimeArtifact(input, boundaries(events)));
|
|
144
|
+
assert.deepEqual(events, []);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("right-release routes model promote to the portable promotion lane", () => {
|
|
149
|
+
const result = spawnSync(process.execPath, [path.join(packageRoot, "cli/right-release.mjs"), "model", "promote", "--authority", "heardright"], {
|
|
150
|
+
cwd: packageRoot,
|
|
151
|
+
encoding: "utf8",
|
|
152
|
+
windowsHide: true,
|
|
153
|
+
});
|
|
154
|
+
assert.equal(result.status, 1);
|
|
155
|
+
assert.match(result.stderr, /model promote requires --config/);
|
|
156
|
+
assert.doesNotMatch(result.stderr, /unknown command/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("model promote help documents the portable config and secure key inputs", () => {
|
|
160
|
+
const result = spawnSync(process.execPath, [path.join(packageRoot, "cli/right-release.mjs"), "model", "promote", "--help"], {
|
|
161
|
+
cwd: packageRoot,
|
|
162
|
+
encoding: "utf8",
|
|
163
|
+
windowsHide: true,
|
|
164
|
+
});
|
|
165
|
+
assert.equal(result.status, 0, result.stderr);
|
|
166
|
+
assert.match(result.stdout, /artifact\.file/);
|
|
167
|
+
assert.match(result.stdout, /evidence\.sha256/);
|
|
168
|
+
assert.match(result.stdout, /RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE/);
|
|
169
|
+
assert.match(result.stdout, /RIGHTAPPS_RELEASE_TOKEN/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("portable CLI dry-run validates real files and signs without network mutation or secret output", async () => {
|
|
173
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "rightkit-model-promote-test-"));
|
|
174
|
+
try {
|
|
175
|
+
const artifact = Buffer.from("tiny model fixture");
|
|
176
|
+
const evidence = Buffer.from('{"certified":true}');
|
|
177
|
+
const testKeys = generateKeyPairSync("ed25519");
|
|
178
|
+
const keyPem = testKeys.privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
179
|
+
await Promise.all([
|
|
180
|
+
writeFile(path.join(root, "model.onnx"), artifact),
|
|
181
|
+
writeFile(path.join(root, "evidence.json"), evidence),
|
|
182
|
+
writeFile(path.join(root, "key.pem"), keyPem, { mode: 0o600 }),
|
|
183
|
+
]);
|
|
184
|
+
const config = {
|
|
185
|
+
artifact: {
|
|
186
|
+
file: "model.onnx",
|
|
187
|
+
filename: "model.onnx",
|
|
188
|
+
sha256: createHash("sha256").update(artifact).digest("hex"),
|
|
189
|
+
sizeBytes: artifact.length,
|
|
190
|
+
},
|
|
191
|
+
evidence: {
|
|
192
|
+
file: "evidence.json",
|
|
193
|
+
sha256: createHash("sha256").update(evidence).digest("hex"),
|
|
194
|
+
},
|
|
195
|
+
manifest: request().config.manifest,
|
|
196
|
+
};
|
|
197
|
+
await writeFile(path.join(root, "promotion.json"), JSON.stringify(config));
|
|
198
|
+
|
|
199
|
+
const result = spawnSync(process.execPath, [
|
|
200
|
+
path.join(packageRoot, "cli/right-release.mjs"), "model", "promote",
|
|
201
|
+
"--authority", "heardright",
|
|
202
|
+
"--config", path.join(root, "promotion.json"),
|
|
203
|
+
"--signing-key-file", path.join(root, "key.pem"),
|
|
204
|
+
"--dry-run",
|
|
205
|
+
], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
206
|
+
assert.equal(result.status, 0, result.stderr);
|
|
207
|
+
assert.match(result.stdout, /\[dry-run\] runtime artifact promotion validated/);
|
|
208
|
+
assert.match(result.stdout, /upload immutable object -> register signed envelope -> replace stable pointer/);
|
|
209
|
+
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /BEGIN PRIVATE KEY/);
|
|
210
|
+
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /Uploading|POST https:/);
|
|
211
|
+
} finally {
|
|
212
|
+
await rm(root, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("uses the canonical Windows signing-key path and supports a Keychain-fed PEM environment value", async () => {
|
|
217
|
+
assert.equal(
|
|
218
|
+
defaultWindowsRuntimeArtifactSigningKeyFile({ APPDATA: "C:/Users/test/AppData/Roaming" }),
|
|
219
|
+
path.join("C:/Users/test/AppData/Roaming", "RightKit", "runtime-artifact-signing-key.pem"),
|
|
220
|
+
);
|
|
221
|
+
assert.equal(
|
|
222
|
+
await loadRuntimeArtifactSigningKey({ env: { RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY: "line1\\nline2" }, platform: "darwin" }),
|
|
223
|
+
"line1\nline2",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("does not forward signing or RightApps credentials into the Wrangler uploader process", () => {
|
|
228
|
+
const env = runtimeArtifactUploadEnv({
|
|
229
|
+
CLOUDFLARE_API_TOKEN: "cloudflare-token",
|
|
230
|
+
RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY: "private-pem",
|
|
231
|
+
RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE: "key.pem",
|
|
232
|
+
RIGHTAPPS_RELEASE_TOKEN: "release-token",
|
|
233
|
+
RIGHTAPPS_RELEASE_TOKEN_FILE: "release.token",
|
|
234
|
+
});
|
|
235
|
+
assert.equal(env.CLOUDFLARE_API_TOKEN, "cloudflare-token");
|
|
236
|
+
assert.equal(env.RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY, undefined);
|
|
237
|
+
assert.equal(env.RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE, undefined);
|
|
238
|
+
assert.equal(env.RIGHTAPPS_RELEASE_TOKEN, undefined);
|
|
239
|
+
assert.equal(env.RIGHTAPPS_RELEASE_TOKEN_FILE, undefined);
|
|
240
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
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,6 +14,11 @@
|
|
|
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
|
+
},
|
|
17
22
|
"publishConfig": {
|
|
18
23
|
"registry": "https://registry.npmjs.org/",
|
|
19
24
|
"access": "public"
|
|
@@ -23,8 +28,5 @@
|
|
|
23
28
|
"url": "git+https://github.com/adrdsouza/claude.git",
|
|
24
29
|
"directory": "tools/rightkit/packages/release"
|
|
25
30
|
},
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
"doctor:all": "node --test right-suite-contract.test.mjs"
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
+
"packageManager": "pnpm@11.12.0"
|
|
32
|
+
}
|
|
@@ -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
|
+
});
|
package/publish-update.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { pruneReleaseObjects } from "./prune-r2.mjs";
|
|
8
8
|
import { loadReleaseToken } from "./release-token.mjs";
|
|
9
|
+
import { registerRightAppsRelease } from "./rightapps-register.mjs";
|
|
9
10
|
|
|
10
11
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const UPLOAD = path.join(TOOL_ROOT, "upload-large.mjs");
|
|
@@ -272,18 +273,12 @@ function patchInstallerKey(artifact) {
|
|
|
272
273
|
}
|
|
273
274
|
|
|
274
275
|
async function register(route, body) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
"x-store-slug": process.env.RIGHTAPPS_STORE_SLUG || "rightapps",
|
|
282
|
-
},
|
|
283
|
-
body: JSON.stringify(body),
|
|
284
|
-
});
|
|
285
|
-
if (!response.ok) fail(`registration failed: ${response.status} ${await response.text()}`);
|
|
286
|
-
console.log(await response.text());
|
|
276
|
+
try {
|
|
277
|
+
const response = await registerRightAppsRelease(route, body, { token: releaseToken });
|
|
278
|
+
if (response !== null) console.log(typeof response === "string" ? response : JSON.stringify(response));
|
|
279
|
+
} catch (error) {
|
|
280
|
+
fail(error.message);
|
|
281
|
+
}
|
|
287
282
|
}
|
|
288
283
|
|
|
289
284
|
function fail(message) {
|
package/qa-contract.mjs
ADDED
|
@@ -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,7 @@ 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";
|
|
8
9
|
|
|
9
10
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
|
|
@@ -141,6 +142,8 @@ if (opts.upload && !target.publish) {
|
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
sweepStaleRustArtifacts();
|
|
146
|
+
|
|
144
147
|
console.log(`right-release: done (${Date.now() - started}ms)`);
|
|
145
148
|
releaseLock.release();
|
|
146
149
|
|
|
@@ -160,6 +163,32 @@ async function mustExist(file, message) {
|
|
|
160
163
|
await access(file).catch(() => fail(message));
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
// Post-release target/ hygiene: `cargo sweep --installed` deletes artifacts left
|
|
167
|
+
// by toolchains rustup no longer has (each update orphans a multi-GB pile per
|
|
168
|
+
// app) and never touches anything the current toolchains produced. Runs after
|
|
169
|
+
// publish so it cannot race an artifact, and is best-effort: a missing
|
|
170
|
+
// cargo-sweep or a sweep failure must never fail a release.
|
|
171
|
+
function sweepStaleRustArtifacts() {
|
|
172
|
+
const candidates = [...new Set([root, workdir].flatMap((base) => [base, path.join(base, "src-tauri")]))];
|
|
173
|
+
const projects = candidates.filter((dir) => existsSync(path.join(dir, "target")));
|
|
174
|
+
for (const dir of projects) {
|
|
175
|
+
if (opts.dryRun) {
|
|
176
|
+
console.log(`dry-run: cargo sweep --installed ${dir}`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const result = spawnSync("cargo", ["sweep", "--installed", dir], { stdio: "inherit" });
|
|
181
|
+
if (result.status !== 0) {
|
|
182
|
+
console.log("right-release: stale-artifact sweep skipped (install with: cargo install cargo-sweep)");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
console.log("right-release: stale-artifact sweep skipped (install with: cargo install cargo-sweep)");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
163
192
|
async function runInstall(pm, cwd) {
|
|
164
193
|
if (pm === "pnpm") await run("pnpm", ["install", "--frozen-lockfile"], cwd);
|
|
165
194
|
else if (pm === "npm") await run("npm", ["ci"], cwd);
|
|
@@ -180,6 +209,7 @@ async function validateRightKitPackageContract(root, appName) {
|
|
|
180
209
|
const packageJsonPath = path.join(root, "package.json");
|
|
181
210
|
const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
182
211
|
const scripts = pkg.scripts ?? {};
|
|
212
|
+
assertQaBackdoorContract(root, scripts);
|
|
183
213
|
const workflowDir = path.join(root, ".github", "workflows");
|
|
184
214
|
if (existsSync(workflowDir)) {
|
|
185
215
|
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");
|