@rightkit/release 0.2.31 → 0.2.33
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/build-release.mjs +388 -0
- package/cli/right-release.mjs +14 -9
- package/legal-contract.mjs +9 -2
- package/legal-contract.test.mjs +4 -0
- package/package.json +1 -1
- package/pub-upload.mjs +100 -0
- package/release-cli-contract.test.mjs +43 -0
- package/release-state.mjs +123 -0
- package/release-state.test.mjs +235 -0
- package/release.mjs +3 -3
- package/release.test.mjs +14 -17
- package/right-suite-contract.test.mjs +2 -3
- package/rightkit-versions.json +3 -4
- package/upload-release.mjs +221 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { runUploadStateMachine, verifySealedRelease } from "./release-state.mjs";
|
|
8
|
+
|
|
9
|
+
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const PUBLISH_UPDATE = path.join(TOOL_ROOT, "publish-update.mjs");
|
|
11
|
+
const HARDENING = path.join(TOOL_ROOT, "hardeningscan.mjs");
|
|
12
|
+
const UPLOAD = path.join(TOOL_ROOT, "upload-large.mjs");
|
|
13
|
+
const PUBLIC_BASE = (process.env.RIGHTAPPS_PUBLIC_DOWNLOAD_BASE || "https://pub-6c73208d46c245a9b4881d5e02f6b618.r2.dev").replace(/\/$/, "");
|
|
14
|
+
const API_BASE = (process.env.RIGHTAPPS_API_URL || "https://api.spoares.com").replace(/\/$/, "");
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
let platform = process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform;
|
|
17
|
+
let releaseId = "";
|
|
18
|
+
let tier = "";
|
|
19
|
+
let dryRun = false;
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
22
|
+
const arg = args[i];
|
|
23
|
+
if (arg === "--platform") platform = args[++i];
|
|
24
|
+
else if (arg === "--release") releaseId = args[++i];
|
|
25
|
+
else if (arg === "--tier") tier = args[++i];
|
|
26
|
+
else if (arg.startsWith("--tier=")) tier = arg.slice("--tier=".length);
|
|
27
|
+
else if (arg === "--dry-run") dryRun = true;
|
|
28
|
+
else if (arg === "-h" || arg === "--help") usage(0);
|
|
29
|
+
else fail(`unknown argument: ${arg}`);
|
|
30
|
+
}
|
|
31
|
+
if (tier !== "patch" && tier !== "update") fail("tier is required (patch|update)");
|
|
32
|
+
if (!releaseId) fail("--release is required");
|
|
33
|
+
if (platform !== "win" && platform !== "mac") fail("--platform must be win or mac");
|
|
34
|
+
|
|
35
|
+
const repoRoot = git(process.cwd(), ["rev-parse", "--show-toplevel"]);
|
|
36
|
+
const platformDir = platform === "win" ? "windows" : "mac";
|
|
37
|
+
const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir);
|
|
38
|
+
const sealed = verifySealedRelease(sealedDir);
|
|
39
|
+
const stateRoot = path.join(repoRoot, ".right-release", "state", releaseId, platformDir);
|
|
40
|
+
const backupRoot = path.join(stateRoot, "rollback", tier);
|
|
41
|
+
const verifiedMarker = path.join(stateRoot, `verified-${tier}.json`);
|
|
42
|
+
if (existsSync(verifiedMarker)) {
|
|
43
|
+
console.log(`right-release upload: already verified ${releaseId} tier=${tier}`);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
if (!dryRun && !process.env.CLOUDFLARE_API_TOKEN) fail("CLOUDFLARE_API_TOKEN is required before R2 mutation");
|
|
47
|
+
|
|
48
|
+
await runUploadStateMachine({
|
|
49
|
+
root: repoRoot,
|
|
50
|
+
releaseId,
|
|
51
|
+
platform,
|
|
52
|
+
tier,
|
|
53
|
+
ops: {
|
|
54
|
+
verifyAuthenticode: async (release) => verifyPlatformTrust(release),
|
|
55
|
+
harden: async (release) => {
|
|
56
|
+
const files = release.manifest.files.filter((file) => file.role !== "updater-signature").map((file) => path.join(release.sealedDir, file.name));
|
|
57
|
+
runChecked(process.execPath, [HARDENING, ...files], repoRoot);
|
|
58
|
+
},
|
|
59
|
+
backup: async (release, routes) => backupStableObjects(release, routes),
|
|
60
|
+
upload: async (release) => publishSealed(release),
|
|
61
|
+
register: async () => {},
|
|
62
|
+
verifyRemote: async (release, routes) => verifyUploaded(release, routes),
|
|
63
|
+
restore: async (_release, routes) => restoreStableObjects(routes),
|
|
64
|
+
discardBackup: async () => rmSync(backupRoot, { recursive: true, force: true }),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
writeJson(verifiedMarker, { schema: 1, releaseId, platform, tier, verifiedAt: new Date().toISOString() });
|
|
68
|
+
console.log(`right-release upload: verified ${releaseId} tier=${tier}`);
|
|
69
|
+
|
|
70
|
+
function verifyPlatformTrust(release) {
|
|
71
|
+
if (dryRun) return;
|
|
72
|
+
const artifacts = release.manifest.files.filter((file) => file.role !== "updater-signature");
|
|
73
|
+
if (platform === "win") {
|
|
74
|
+
for (const artifact of artifacts) {
|
|
75
|
+
const file = path.join(release.sealedDir, artifact.name);
|
|
76
|
+
const escaped = file.replaceAll("'", "''");
|
|
77
|
+
const status = commandOutput("powershell", ["-NoProfile", "-Command", `(Get-AuthenticodeSignature -LiteralPath '${escaped}').Status`]);
|
|
78
|
+
if (status.trim() !== "Valid") throw new Error(`Authenticode verification failed for ${artifact.name}: ${status}`);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
for (const artifact of artifacts.filter((file) => /\.dmg$/i.test(file.name))) {
|
|
82
|
+
const file = path.join(release.sealedDir, artifact.name);
|
|
83
|
+
runChecked("codesign", ["--verify", "--strict", "--verbose=2", file], repoRoot);
|
|
84
|
+
runChecked("xcrun", ["stapler", "validate", file], repoRoot);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function backupStableObjects(release, routes) {
|
|
90
|
+
if (dryRun) return;
|
|
91
|
+
if (existsSync(path.join(backupRoot, "backup-manifest.json"))) restoreStableObjects(routes);
|
|
92
|
+
mkdirSync(backupRoot, { recursive: true });
|
|
93
|
+
const entries = [];
|
|
94
|
+
for (const route of uniqueRoutes(routes)) {
|
|
95
|
+
const bucket = bucketName(route.bucket);
|
|
96
|
+
const file = path.join(backupRoot, `${createHash("sha256").update(`${bucket}/${route.key}`).digest("hex").slice(0, 12)}.bin`);
|
|
97
|
+
const result = wrangler(["r2", "object", "get", `${bucket}/${route.key}`, "--file", file, "--remote"]);
|
|
98
|
+
if (result.status === 0) entries.push({ bucket: route.bucket, key: route.key, existed: true, file: path.basename(file), sha256: hashFile(file) });
|
|
99
|
+
else if (/not found|does not exist|404/i.test(`${result.stdout}\n${result.stderr}`)) entries.push({ bucket: route.bucket, key: route.key, existed: false, file: null, sha256: null });
|
|
100
|
+
else throw new Error(`failed to back up R2 object ${bucket}/${route.key}: ${result.stderr || result.stdout}`);
|
|
101
|
+
}
|
|
102
|
+
writeJson(path.join(backupRoot, "backup-manifest.json"), { schema: 1, releaseId: release.manifest.releaseId, entries });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function publishSealed(release) {
|
|
106
|
+
if (dryRun) return;
|
|
107
|
+
const generated = path.join(stateRoot, `sealed-upload-${tier}.config.mjs`);
|
|
108
|
+
const config = sealedUploadConfig(release);
|
|
109
|
+
writeFileSync(generated, `export default ${JSON.stringify(config, null, 2)};\n`);
|
|
110
|
+
runChecked(process.execPath, [PUBLISH_UPDATE, "--config", generated, "--platform", platform], repoRoot, { ...process.env, RIGHT_RELEASE_TIER: tier });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sealedUploadConfig(release) {
|
|
114
|
+
const fileByName = new Map(release.manifest.files.map((file) => [file.name, path.join(release.sealedDir, file.name)]));
|
|
115
|
+
const patchRoutes = release.manifest.routes.patch ?? [];
|
|
116
|
+
const updateRoutes = release.manifest.routes.update ?? [];
|
|
117
|
+
const installers = patchRoutes.filter((route) => route.role === "installer").map((route) => ({ file: fileByName.get(route.name), key: route.key }));
|
|
118
|
+
const updaterRoutes = updateRoutes.map((route) => {
|
|
119
|
+
const patch = patchRoutes.find((candidate) => candidate.name === route.name && candidate.platform === route.platform);
|
|
120
|
+
return { file: fileByName.get(route.name), signature: fileByName.get(route.signature), platform: route.platform, key: route.key, patchKey: patch?.key };
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
schema: 1,
|
|
124
|
+
app: release.manifest.app,
|
|
125
|
+
version: release.manifest.version,
|
|
126
|
+
channel: "stable",
|
|
127
|
+
targets: { [platform]: { installer: { artifacts: installers }, updater: { artifacts: updaterRoutes } } },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function verifyUploaded(release, routes) {
|
|
132
|
+
if (dryRun) return;
|
|
133
|
+
const files = new Map(release.manifest.files.map((file) => [file.name, file]));
|
|
134
|
+
for (const route of uniqueRoutes(routes)) {
|
|
135
|
+
const expected = files.get(route.name);
|
|
136
|
+
if (!expected) throw new Error(`route references unsealed file: ${route.name}`);
|
|
137
|
+
const verifyFile = path.join(stateRoot, `remote-${createHash("sha256").update(route.key).digest("hex").slice(0, 12)}.bin`);
|
|
138
|
+
if (route.bucket === "public") {
|
|
139
|
+
const response = await fetch(`${PUBLIC_BASE}/${route.key}`, { cache: "no-store" });
|
|
140
|
+
if (!response.ok) throw new Error(`public artifact verification failed: ${response.status} ${route.key}`);
|
|
141
|
+
writeFileSync(verifyFile, Buffer.from(await response.arrayBuffer()));
|
|
142
|
+
} else {
|
|
143
|
+
const result = wrangler(["r2", "object", "get", `${bucketName(route.bucket)}/${route.key}`, "--file", verifyFile, "--remote"]);
|
|
144
|
+
if (result.status !== 0) throw new Error(`private artifact verification failed: ${route.key}`);
|
|
145
|
+
}
|
|
146
|
+
if (statSync(verifyFile).size !== expected.sizeBytes || hashFile(verifyFile) !== expected.sha256) throw new Error(`remote hash mismatch: ${route.key}`);
|
|
147
|
+
rmSync(verifyFile, { force: true });
|
|
148
|
+
}
|
|
149
|
+
if (!routes.some((route) => route.role === "updater")) return;
|
|
150
|
+
const apiPlatform = platform === "win" ? "windows-x86_64" : "darwin-aarch64";
|
|
151
|
+
const response = await fetch(`${API_BASE}/v1/apps/${release.manifest.app}/releases/latest.json?platform=${apiPlatform}`, { cache: "no-store" });
|
|
152
|
+
if (!response.ok) throw new Error(`updater API verification failed: ${response.status}`);
|
|
153
|
+
const body = await response.json();
|
|
154
|
+
if (body.version !== release.manifest.version) throw new Error(`updater API version mismatch: expected ${release.manifest.version}, got ${body.version}`);
|
|
155
|
+
const expectedRoutes = new Set(routes.map((route) => route.key));
|
|
156
|
+
const urls = Object.values(body.platforms ?? {}).map((entry) => entry.url).filter(Boolean);
|
|
157
|
+
if (!urls.some((url) => [...expectedRoutes].some((key) => url.endsWith(key)))) throw new Error("updater API returned the wrong artifact URL");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function restoreStableObjects(routes) {
|
|
161
|
+
const manifestPath = path.join(backupRoot, "backup-manifest.json");
|
|
162
|
+
if (!existsSync(manifestPath)) return;
|
|
163
|
+
const backup = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
164
|
+
for (const entry of backup.entries) {
|
|
165
|
+
if (entry.existed) {
|
|
166
|
+
const file = path.join(backupRoot, entry.file);
|
|
167
|
+
if (hashFile(file) !== entry.sha256) throw new Error(`rollback copy hash mismatch: ${entry.key}`);
|
|
168
|
+
runChecked(process.execPath, [UPLOAD, file, entry.key, entry.bucket], repoRoot);
|
|
169
|
+
} else {
|
|
170
|
+
const result = wrangler(["r2", "object", "delete", `${bucketName(entry.bucket)}/${entry.key}`, "--remote"]);
|
|
171
|
+
if (result.status !== 0 && !/not found|404/i.test(`${result.stdout}\n${result.stderr}`)) throw new Error(`rollback delete failed: ${entry.key}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
rmSync(backupRoot, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function uniqueRoutes(routes) {
|
|
178
|
+
return [...new Map(routes.map((route) => [`${route.bucket}/${route.key}`, route])).values()];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function bucketName(bucket) {
|
|
182
|
+
return bucket === "public" ? "rightapps-downloads" : "rightapps-updates";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function wrangler(runArgs) {
|
|
186
|
+
return spawnSync("npx", ["wrangler@4", ...runArgs], { cwd: repoRoot, env: process.env, encoding: "utf8", windowsHide: true, shell: process.platform === "win32" });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hashFile(file) {
|
|
190
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function git(cwd, runArgs) {
|
|
194
|
+
return commandOutput("git", runArgs, cwd);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function commandOutput(cmd, runArgs, cwd = process.cwd()) {
|
|
198
|
+
const result = spawnSync(cmd, runArgs, { cwd, encoding: "utf8", windowsHide: true });
|
|
199
|
+
if (result.status !== 0) fail(`${cmd} ${runArgs.join(" ")} failed: ${result.stderr}`);
|
|
200
|
+
return result.stdout.trim();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function runChecked(cmd, runArgs, cwd, env = process.env) {
|
|
204
|
+
const result = spawnSync(cmd, runArgs, { cwd, env, stdio: "inherit", windowsHide: true, shell: false });
|
|
205
|
+
if (result.status !== 0) throw new Error(`${cmd} exited ${result.status}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function writeJson(file, value) {
|
|
209
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
210
|
+
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function usage(code) {
|
|
214
|
+
console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update");
|
|
215
|
+
process.exit(code);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function fail(message) {
|
|
219
|
+
console.error(`right-release upload: ${message}`);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|