@rightkit/release 0.2.19 → 0.2.21
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/cargo-contract.mjs +159 -0
- package/cli/right-release.mjs +9 -0
- package/model-promote.mjs +262 -0
- package/model-promote.test.mjs +240 -0
- package/package.json +3 -2
- package/publish-update.mjs +7 -12
- package/release.mjs +2 -0
- package/release.test.mjs +64 -2
- package/right-suite-contract.test.mjs +315 -22
- package/rightapps-register.mjs +31 -0
- package/rightapps-register.test.mjs +28 -0
- package/rightkit-versions.json +7 -4
- 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,159 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const SKIP_DIRECTORIES = new Set([".cache", ".git", ".claude", ".worktrees", "node_modules", "target", "vendor"]);
|
|
7
|
+
const CRATES_IO_SOURCES = new Set([
|
|
8
|
+
"registry+https://github.com/rust-lang/crates.io-index",
|
|
9
|
+
"registry+https://index.crates.io/",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export function validateRightKitCargoContract(root, publishedVersions, label = path.basename(root)) {
|
|
13
|
+
const scanRoot = path.resolve(root);
|
|
14
|
+
const boundary = findRepositoryRoot(scanRoot);
|
|
15
|
+
const published = publishedVersions instanceof Map
|
|
16
|
+
? publishedVersions
|
|
17
|
+
: new Map(Object.entries(publishedVersions ?? {}));
|
|
18
|
+
const manifests = findCargoManifests(scanRoot);
|
|
19
|
+
const cargoHome = mkdtempSync(path.join(tmpdir(), "rightkit-cargo-home-"));
|
|
20
|
+
try {
|
|
21
|
+
for (const manifestPath of manifests) {
|
|
22
|
+
const manifestLabel = path.relative(boundary, manifestPath) || "Cargo.toml";
|
|
23
|
+
assertNoRightKitCargoOverrides(manifestPath, boundary, `${label}/${manifestLabel}`);
|
|
24
|
+
const dependencies = readCargoManifestDependencies(manifestPath, cargoHome, `${label}/${manifestLabel}`);
|
|
25
|
+
assertPublishedRightKitCargoDependencies(dependencies, published, `${label}/${manifestLabel}`);
|
|
26
|
+
}
|
|
27
|
+
} finally {
|
|
28
|
+
rmSync(cargoHome, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
return manifests.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function assertPublishedRightKitCargoDependencies(dependencies, published, label) {
|
|
34
|
+
let checked = 0;
|
|
35
|
+
for (const dependency of dependencies.filter(({ name }) => name.startsWith("rightkit-"))) {
|
|
36
|
+
checked += 1;
|
|
37
|
+
const expected = published.get(dependency.name);
|
|
38
|
+
if (!expected) throw new Error(`${label} ${dependency.name} is not a published RightKit crate`);
|
|
39
|
+
if (!CRATES_IO_SOURCES.has(dependency.source) || dependency.path || dependency.registry || dependency.req !== `=${expected}`) {
|
|
40
|
+
throw new Error(`${label} ${dependency.name} must use the exact crates.io version "=${expected}"`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return checked;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function assertNoRightKitCargoOverrides(manifestPath, repoRoot, label) {
|
|
47
|
+
const boundary = path.resolve(repoRoot);
|
|
48
|
+
const manifest = path.resolve(manifestPath);
|
|
49
|
+
assertInsideBoundary(manifest, boundary);
|
|
50
|
+
assertNoRightKitOverrides(readToml(manifest, label), label, path.relative(boundary, manifest) || "Cargo.toml");
|
|
51
|
+
|
|
52
|
+
for (const configPath of findRepoCargoConfigs(path.dirname(manifest), boundary)) {
|
|
53
|
+
const source = path.relative(boundary, configPath);
|
|
54
|
+
const parsed = readToml(configPath, source);
|
|
55
|
+
assertNoRightKitOverrides(parsed, label, source);
|
|
56
|
+
const cratesIo = parsed.source?.["crates-io"];
|
|
57
|
+
if (cratesIo && typeof cratesIo === "object"
|
|
58
|
+
&& ["replace-with", "directory", "git", "registry", "local-registry"].some((key) => key in cratesIo)) {
|
|
59
|
+
throw new Error(`${label} has forbidden crates.io source replacement in ${source}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readCargoManifestDependencies(manifestPath, cargoHome, label) {
|
|
65
|
+
const result = spawnSync(
|
|
66
|
+
"cargo",
|
|
67
|
+
["metadata", "--no-deps", "--offline", "--format-version", "1", "--manifest-path", manifestPath],
|
|
68
|
+
{
|
|
69
|
+
cwd: path.dirname(manifestPath),
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
env: { ...process.env, CARGO_HOME: cargoHome },
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
if (result.status !== 0) {
|
|
76
|
+
throw new Error(`${label} Cargo metadata rejected manifest; RightKit dependencies must use exact crates.io versions: ${String(result.stderr ?? "").trim()}`);
|
|
77
|
+
}
|
|
78
|
+
const metadata = JSON.parse(result.stdout);
|
|
79
|
+
const expectedPath = path.resolve(manifestPath).toLowerCase();
|
|
80
|
+
const pkg = metadata.packages.find(({ manifest_path: candidate }) => path.resolve(candidate).toLowerCase() === expectedPath);
|
|
81
|
+
if (!pkg && path.resolve(metadata.workspace_root, "Cargo.toml").toLowerCase() === expectedPath) return [];
|
|
82
|
+
if (!pkg) throw new Error(`${label} was not returned by Cargo metadata`);
|
|
83
|
+
return pkg.dependencies;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertNoRightKitOverrides(parsed, label, source) {
|
|
87
|
+
for (const overrides of Object.values(parsed.patch ?? {})) {
|
|
88
|
+
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) continue;
|
|
89
|
+
for (const [name, specifier] of Object.entries(overrides)) {
|
|
90
|
+
const packageName = typeof specifier === "object" && specifier ? specifier.package : undefined;
|
|
91
|
+
if (name.startsWith("rightkit-") || packageName?.startsWith("rightkit-")) {
|
|
92
|
+
throw new Error(`${label} has forbidden RightKit Cargo override ${name} in ${source}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const [name, specifier] of Object.entries(parsed.replace ?? {})) {
|
|
97
|
+
const packageName = typeof specifier === "object" && specifier ? specifier.package : undefined;
|
|
98
|
+
if (name.split(":", 1)[0].startsWith("rightkit-") || packageName?.startsWith("rightkit-")) {
|
|
99
|
+
throw new Error(`${label} has forbidden RightKit Cargo override ${name} in ${source}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readToml(file, label) {
|
|
105
|
+
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 });
|
|
109
|
+
if (result.status !== 0) throw new Error(`${label} is not valid TOML: ${String(result.stderr ?? "").trim()}`);
|
|
110
|
+
return JSON.parse(result.stdout);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function findCargoManifests(root) {
|
|
114
|
+
const found = [];
|
|
115
|
+
const visit = (dir) => {
|
|
116
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
117
|
+
if (entry.isDirectory() && SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
118
|
+
const full = path.join(dir, entry.name);
|
|
119
|
+
if (entry.isDirectory()) visit(full);
|
|
120
|
+
else if (entry.isFile() && entry.name === "Cargo.toml") found.push(full);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
visit(root);
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function findRepoCargoConfigs(start, repoRoot) {
|
|
128
|
+
const boundary = path.resolve(repoRoot);
|
|
129
|
+
let current = path.resolve(start);
|
|
130
|
+
assertInsideBoundary(current, boundary);
|
|
131
|
+
const configs = [];
|
|
132
|
+
while (true) {
|
|
133
|
+
for (const filename of ["config", "config.toml"]) {
|
|
134
|
+
const candidate = path.join(current, ".cargo", filename);
|
|
135
|
+
if (existsSync(candidate)) configs.push(candidate);
|
|
136
|
+
}
|
|
137
|
+
if (current.toLowerCase() === boundary.toLowerCase()) break;
|
|
138
|
+
current = path.dirname(current);
|
|
139
|
+
}
|
|
140
|
+
return configs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function assertInsideBoundary(candidate, boundary) {
|
|
144
|
+
const relative = path.relative(boundary, candidate);
|
|
145
|
+
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
|
|
146
|
+
throw new Error(`${candidate} is outside ${boundary}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function findRepositoryRoot(start) {
|
|
151
|
+
const original = path.resolve(start);
|
|
152
|
+
let current = original;
|
|
153
|
+
while (true) {
|
|
154
|
+
if (existsSync(path.join(current, ".git"))) return current;
|
|
155
|
+
const parent = path.dirname(current);
|
|
156
|
+
if (parent === current) return original;
|
|
157
|
+
current = parent;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/cli/right-release.mjs
CHANGED
|
@@ -47,6 +47,13 @@ if (first === "--version" || first === "-v") {
|
|
|
47
47
|
} else {
|
|
48
48
|
run("release.mjs", rest.includes("--upload") || rest.includes("--publish") ? rest : [...rest, "--upload"]);
|
|
49
49
|
}
|
|
50
|
+
} else if (first === "model") {
|
|
51
|
+
const rest = args.slice(1);
|
|
52
|
+
if (rest[0] !== "promote") {
|
|
53
|
+
console.error("right-release model: expected promote");
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
run("model-promote.mjs", rest.slice(1));
|
|
50
57
|
} else if (first === "lsclean") {
|
|
51
58
|
runBinary("bash", [path.join(packageRoot, "lsclean.sh"), ...args.slice(1)]);
|
|
52
59
|
} else if (first === "generate-dmg-background") {
|
|
@@ -113,6 +120,8 @@ Commands:
|
|
|
113
120
|
release [--platform mac|win] --tier patch|update Build/package through the signed release lane
|
|
114
121
|
publish [--platform mac|win] --tier patch|update Release plus R2 upload + RightApps registration
|
|
115
122
|
publish cargo --crate <name> [--dry-run] Test, inspect, scan, and publish one crates.io package
|
|
123
|
+
model promote --authority heardright --config <file> [--dry-run]
|
|
124
|
+
Sign and promote one runtime/model artifact
|
|
116
125
|
doctor [--platform mac|win] Inspect one app's release config
|
|
117
126
|
doctor --all Verify all Right Suite app release contracts
|
|
118
127
|
suite-doctor Verify all local Right Suite repositories
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
buildRuntimeArtifactManifest,
|
|
12
|
+
signRuntimeArtifactManifest,
|
|
13
|
+
} from "./runtime-artifact-manifest.mjs";
|
|
14
|
+
import { loadReleaseToken } from "./release-token.mjs";
|
|
15
|
+
import { registerRightAppsRelease } from "./rightapps-register.mjs";
|
|
16
|
+
|
|
17
|
+
export const RUNTIME_ARTIFACT_TRUSTED_KEY_ID = "rightkit-runtime-artifacts-2026-07";
|
|
18
|
+
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const UPLOAD = path.join(TOOL_ROOT, "upload-large.mjs");
|
|
20
|
+
|
|
21
|
+
export async function promoteRuntimeArtifact(input, deps = {}) {
|
|
22
|
+
const metadata = deps.metadata ?? fileMetadata;
|
|
23
|
+
const upload = deps.upload;
|
|
24
|
+
const register = deps.register;
|
|
25
|
+
const { config } = input;
|
|
26
|
+
assertPromotionConfig(config);
|
|
27
|
+
if (input.authority !== "heardright") throw new Error("model promotion authority must be heardright");
|
|
28
|
+
if (input.keyId !== RUNTIME_ARTIFACT_TRUSTED_KEY_ID) throw new Error(`untrusted runtime artifact key id: ${input.keyId}`);
|
|
29
|
+
if (!input.privateKey) throw new Error("runtime artifact signing key is required");
|
|
30
|
+
|
|
31
|
+
const [artifact, evidence] = await Promise.all([
|
|
32
|
+
metadata(input.artifactFile),
|
|
33
|
+
metadata(input.evidenceFile),
|
|
34
|
+
]);
|
|
35
|
+
assertMetadata(artifact, config.artifact, "artifact");
|
|
36
|
+
if (evidence.sha256 !== config.evidence.sha256) throw new Error("evidence SHA-256 does not match promotion config");
|
|
37
|
+
|
|
38
|
+
const manifest = buildRuntimeArtifactManifest({
|
|
39
|
+
...config.manifest,
|
|
40
|
+
filename: config.artifact.filename,
|
|
41
|
+
sha256: artifact.sha256,
|
|
42
|
+
sizeBytes: artifact.sizeBytes,
|
|
43
|
+
promotion: {
|
|
44
|
+
...config.manifest.promotion,
|
|
45
|
+
authorityAppKey: input.authority,
|
|
46
|
+
evidenceSha256: evidence.sha256,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const envelope = signRuntimeArtifactManifest(manifest, input.privateKey, input.keyId);
|
|
50
|
+
const isR2 = manifest.distribution.delivery !== "bundled";
|
|
51
|
+
const steps = isR2
|
|
52
|
+
? ["upload immutable object", "register signed envelope", "replace stable pointer"]
|
|
53
|
+
: ["register signed envelope"];
|
|
54
|
+
if (input.dryRun) return { envelope, steps };
|
|
55
|
+
if (typeof register !== "function") throw new Error("RightApps registration boundary is required");
|
|
56
|
+
if (isR2 && typeof upload !== "function") throw new Error("R2 upload boundary is required");
|
|
57
|
+
|
|
58
|
+
if (isR2) {
|
|
59
|
+
await upload({
|
|
60
|
+
purpose: "object",
|
|
61
|
+
file: input.artifactFile,
|
|
62
|
+
key: manifest.object.r2Key,
|
|
63
|
+
lane: manifest.distribution.delivery,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
await register(envelope);
|
|
67
|
+
if (isR2) {
|
|
68
|
+
await upload({
|
|
69
|
+
purpose: "pointer",
|
|
70
|
+
content: `${JSON.stringify(envelope)}\n`,
|
|
71
|
+
key: manifest.pointerKey,
|
|
72
|
+
lane: manifest.distribution.delivery,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { envelope, steps };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function defaultWindowsRuntimeArtifactSigningKeyFile(env = process.env) {
|
|
79
|
+
const appData = env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
80
|
+
return path.join(appData, "RightKit", "runtime-artifact-signing-key.pem");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function loadRuntimeArtifactSigningKey({ env = process.env, platform = process.platform, explicitFile } = {}) {
|
|
84
|
+
const direct = env.RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY;
|
|
85
|
+
if (direct?.trim()) return direct.replace(/\\n/g, "\n");
|
|
86
|
+
const configuredFile = explicitFile || env.RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE;
|
|
87
|
+
if (configuredFile) return readNonemptySecret(path.resolve(configuredFile));
|
|
88
|
+
if (platform === "win32") return readNonemptySecret(defaultWindowsRuntimeArtifactSigningKeyFile(env));
|
|
89
|
+
throw new Error("RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY or RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE is required");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function runtimeArtifactUploadEnv(source = process.env) {
|
|
93
|
+
const env = { ...source };
|
|
94
|
+
delete env.RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY;
|
|
95
|
+
delete env.RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE;
|
|
96
|
+
delete env.RIGHTAPPS_RELEASE_TOKEN;
|
|
97
|
+
delete env.RIGHTAPPS_RELEASE_TOKEN_FILE;
|
|
98
|
+
return env;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function main() {
|
|
102
|
+
if (process.argv.slice(2).some((arg) => arg === "--help" || arg === "-h")) {
|
|
103
|
+
printHelp();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const args = parseArgs(process.argv.slice(2));
|
|
107
|
+
const configPath = path.resolve(args.config);
|
|
108
|
+
const configRoot = path.dirname(configPath);
|
|
109
|
+
const raw = JSON.parse(await readFile(configPath, "utf8"));
|
|
110
|
+
assertExactKeys(raw, ["artifact", "evidence", "manifest"], "promotion config");
|
|
111
|
+
assertExactKeys(raw.artifact, ["file", "filename", "sha256", "sizeBytes"], "artifact");
|
|
112
|
+
assertExactKeys(raw.evidence, ["file", "sha256"], "evidence");
|
|
113
|
+
const config = {
|
|
114
|
+
artifact: {
|
|
115
|
+
filename: raw.artifact.filename,
|
|
116
|
+
sha256: raw.artifact.sha256,
|
|
117
|
+
sizeBytes: raw.artifact.sizeBytes,
|
|
118
|
+
},
|
|
119
|
+
evidence: { sha256: raw.evidence.sha256 },
|
|
120
|
+
manifest: raw.manifest,
|
|
121
|
+
};
|
|
122
|
+
const privateKey = await loadRuntimeArtifactSigningKey({ explicitFile: args.signingKeyFile });
|
|
123
|
+
const releaseToken = args.dryRun ? null : await loadReleaseToken();
|
|
124
|
+
const runtime = {
|
|
125
|
+
metadata: fileMetadata,
|
|
126
|
+
upload: defaultUpload,
|
|
127
|
+
register: (envelope) => registerRuntimeArtifact(envelope, releaseToken),
|
|
128
|
+
};
|
|
129
|
+
const result = await promoteRuntimeArtifact({
|
|
130
|
+
authority: args.authority,
|
|
131
|
+
keyId: args.keyId,
|
|
132
|
+
privateKey,
|
|
133
|
+
artifactFile: path.resolve(configRoot, raw.artifact.file),
|
|
134
|
+
evidenceFile: path.resolve(configRoot, raw.evidence.file),
|
|
135
|
+
config,
|
|
136
|
+
dryRun: args.dryRun,
|
|
137
|
+
}, runtime);
|
|
138
|
+
const manifest = result.envelope.manifest;
|
|
139
|
+
console.log(`${args.dryRun ? "[dry-run] " : ""}runtime artifact promotion validated`);
|
|
140
|
+
console.log(` app/kind: ${manifest.entitlement.appKey}/${manifest.artifactKind}`);
|
|
141
|
+
console.log(` target: ${manifest.target.os}/${manifest.target.arch}`);
|
|
142
|
+
console.log(` delivery: ${manifest.distribution.delivery}`);
|
|
143
|
+
console.log(` object sha256: ${manifest.object.sha256}`);
|
|
144
|
+
console.log(` pointer: ${manifest.pointerKey ?? "bundled"}`);
|
|
145
|
+
console.log(` order: ${result.steps.join(" -> ")}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function printHelp() {
|
|
149
|
+
console.log(`right-release model promote --authority heardright --config <promotion.json> [options]
|
|
150
|
+
|
|
151
|
+
The JSON config has exactly:
|
|
152
|
+
artifact: { file, filename, sha256, sizeBytes }
|
|
153
|
+
evidence: { file, sha256 }
|
|
154
|
+
manifest: { artifactKind, entitlement, distribution, target, versions, provenance,
|
|
155
|
+
promotion: { promotionId, promotedAt } }
|
|
156
|
+
artifact.file and evidence.file resolve relative to the config; artifact.sha256,
|
|
157
|
+
artifact.sizeBytes, and evidence.sha256 must exactly match those files.
|
|
158
|
+
|
|
159
|
+
Options:
|
|
160
|
+
--dry-run Validate real files and sign, without R2 or RightApps mutations
|
|
161
|
+
--signing-key-file <pem> Override the protected PEM path
|
|
162
|
+
--key-id <id> Must equal ${RUNTIME_ARTIFACT_TRUSTED_KEY_ID}
|
|
163
|
+
|
|
164
|
+
Signing key inputs (never printed): RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY or
|
|
165
|
+
RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE. Windows defaults to
|
|
166
|
+
%APPDATA%/RightKit/runtime-artifact-signing-key.pem. Live promotion also uses
|
|
167
|
+
the durable RIGHTAPPS_RELEASE_TOKEN boundary.`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseArgs(args) {
|
|
171
|
+
let authority = "";
|
|
172
|
+
let config = "";
|
|
173
|
+
let signingKeyFile;
|
|
174
|
+
let keyId = process.env.RIGHTKIT_RUNTIME_ARTIFACT_KEY_ID || RUNTIME_ARTIFACT_TRUSTED_KEY_ID;
|
|
175
|
+
let dryRun = false;
|
|
176
|
+
for (let i = 0; i < args.length; i++) {
|
|
177
|
+
const arg = args[i];
|
|
178
|
+
if (arg === "--authority") authority = args[++i] ?? "";
|
|
179
|
+
else if (arg === "--config") config = args[++i] ?? "";
|
|
180
|
+
else if (arg === "--signing-key-file") signingKeyFile = args[++i] ?? "";
|
|
181
|
+
else if (arg === "--key-id") keyId = args[++i] ?? "";
|
|
182
|
+
else if (arg === "--dry-run") dryRun = true;
|
|
183
|
+
else throw new Error(`unknown model promote argument: ${arg}`);
|
|
184
|
+
}
|
|
185
|
+
if (!config) throw new Error("model promote requires --config <path>");
|
|
186
|
+
if (!authority) throw new Error("model promote requires --authority heardright");
|
|
187
|
+
return { authority, config, signingKeyFile, keyId, dryRun };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function fileMetadata(file) {
|
|
191
|
+
const info = await stat(file);
|
|
192
|
+
if (!info.isFile() || info.size <= 0) throw new Error(`promotion input is not a nonempty file: ${file}`);
|
|
193
|
+
const hash = createHash("sha256");
|
|
194
|
+
await new Promise((resolve, reject) => {
|
|
195
|
+
const stream = createReadStream(file);
|
|
196
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
197
|
+
stream.on("error", reject);
|
|
198
|
+
stream.on("end", resolve);
|
|
199
|
+
});
|
|
200
|
+
return { sha256: hash.digest("hex"), sizeBytes: info.size };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function defaultUpload({ file, content, key, lane }) {
|
|
204
|
+
const bucketAlias = lane === "private-r2" ? "private" : "public";
|
|
205
|
+
const env = runtimeArtifactUploadEnv();
|
|
206
|
+
if (file) return run(process.execPath, [UPLOAD, file, key, bucketAlias], env);
|
|
207
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "rightkit-runtime-pointer-"));
|
|
208
|
+
const pointerFile = path.join(tempRoot, "manifest.json");
|
|
209
|
+
try {
|
|
210
|
+
await writeFile(pointerFile, content, { encoding: "utf8", mode: 0o600 });
|
|
211
|
+
await run(process.execPath, [UPLOAD, pointerFile, key, bucketAlias], env);
|
|
212
|
+
} finally {
|
|
213
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function registerRuntimeArtifact(envelope, releaseToken) {
|
|
218
|
+
return registerRightAppsRelease("/v1/admin/apps/runtime-artifacts", envelope, { token: releaseToken });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function run(command, args, env = process.env) {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const child = spawn(command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true });
|
|
224
|
+
child.on("error", reject);
|
|
225
|
+
child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${command} exited ${code}`)));
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function readNonemptySecret(file) {
|
|
230
|
+
const value = await readFile(file, "utf8");
|
|
231
|
+
if (!value.trim()) throw new Error(`runtime artifact signing key file is empty: ${file}`);
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertPromotionConfig(value) {
|
|
236
|
+
assertExactKeys(value, ["artifact", "evidence", "manifest"], "promotion config");
|
|
237
|
+
assertExactKeys(value.artifact, ["filename", "sha256", "sizeBytes"], "artifact");
|
|
238
|
+
assertExactKeys(value.evidence, ["sha256"], "evidence");
|
|
239
|
+
assertExactKeys(value.manifest, [
|
|
240
|
+
"artifactKind", "entitlement", "distribution", "target", "versions", "provenance", "promotion",
|
|
241
|
+
], "manifest config");
|
|
242
|
+
assertExactKeys(value.manifest.promotion, ["promotionId", "promotedAt"], "manifest config promotion");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function assertMetadata(actual, expected, label) {
|
|
246
|
+
if (actual.sha256 !== expected.sha256) throw new Error(`${label} SHA-256 does not match promotion config`);
|
|
247
|
+
if (actual.sizeBytes !== expected.sizeBytes) throw new Error(`${label} size does not match promotion config`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function assertExactKeys(value, expected, label) {
|
|
251
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
252
|
+
const allowed = new Set(expected);
|
|
253
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${label} has unknown field ${key}`);
|
|
254
|
+
for (const key of expected) if (!Object.hasOwn(value, key)) throw new Error(`${label}.${key} is required`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
258
|
+
main().catch((error) => {
|
|
259
|
+
console.error(`right-release model promote: ${error.message}`);
|
|
260
|
+
process.exitCode = 1;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -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
|
+
});
|