@rightkit/release 0.2.31 → 0.2.32
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 +384 -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 +111 -0
- package/release-state.test.mjs +224 -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,384 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
closeSync,
|
|
6
|
+
copyFileSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statfsSync,
|
|
15
|
+
statSync,
|
|
16
|
+
symlinkSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
|
+
import { cacheFingerprint, releaseEnvironment, runBuildStateMachine } from "./release-state.mjs";
|
|
24
|
+
|
|
25
|
+
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const WORKER = path.join(TOOL_ROOT, "release.mjs");
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
let configName = "right-release.config.mjs";
|
|
29
|
+
let platform = process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform;
|
|
30
|
+
let dryRun = false;
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
33
|
+
const arg = args[i];
|
|
34
|
+
if (arg === "--config") configName = args[++i];
|
|
35
|
+
else if (arg === "--platform") platform = args[++i];
|
|
36
|
+
else if (arg === "--dry-run") dryRun = true;
|
|
37
|
+
else if (arg === "--tier" || arg.startsWith("--tier=")) fail("build is tier-neutral; select patch or update only during upload");
|
|
38
|
+
else if (arg === "-h" || arg === "--help") usage(0);
|
|
39
|
+
else fail(`unknown argument: ${arg}`);
|
|
40
|
+
}
|
|
41
|
+
if (platform !== "win" && platform !== "mac") fail("--platform must be win or mac");
|
|
42
|
+
|
|
43
|
+
const invocationRoot = path.dirname(path.resolve(configName));
|
|
44
|
+
const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
|
|
45
|
+
const relativeConfig = path.relative(repoRoot, path.resolve(configName)).replaceAll("\\", "/");
|
|
46
|
+
if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
|
|
47
|
+
const vaultRoot = path.join(repoRoot, ".right-release");
|
|
48
|
+
mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
|
|
49
|
+
const lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
|
|
50
|
+
let child = null;
|
|
51
|
+
|
|
52
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
53
|
+
process.once(signal, () => {
|
|
54
|
+
if (child?.pid) killTree(child.pid);
|
|
55
|
+
lock.release();
|
|
56
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
if (!dryRun) git(repoRoot, ["fetch", "origin", "main"]);
|
|
62
|
+
const commit = git(repoRoot, ["rev-parse", dryRun ? "HEAD" : "origin/main"]);
|
|
63
|
+
const shortCommit = commit.slice(0, 8);
|
|
64
|
+
const worktree = path.join(vaultRoot, "worktrees", `${platform}-${shortCommit}`);
|
|
65
|
+
if (!existsSync(worktree)) {
|
|
66
|
+
if (dryRun) fail(`dry-run needs an existing worktree: ${worktree}`);
|
|
67
|
+
mkdirSync(path.dirname(worktree), { recursive: true });
|
|
68
|
+
runChecked("git", ["worktree", "add", "--detach", worktree, commit], repoRoot);
|
|
69
|
+
} else if (git(worktree, ["rev-parse", "HEAD"]) !== commit) {
|
|
70
|
+
fail(`release worktree exists at the wrong commit: ${worktree}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const worktreeConfigPath = path.join(worktree, relativeConfig);
|
|
74
|
+
const config = (await import(`${pathToFileURL(worktreeConfigPath).href}?commit=${commit}`)).default;
|
|
75
|
+
if (!config?.app || !config?.version) fail("release config must expose app and version");
|
|
76
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(config.app) || !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(config.version)) {
|
|
77
|
+
fail("app and version must be safe release-id components");
|
|
78
|
+
}
|
|
79
|
+
const target = config.targets?.[platform];
|
|
80
|
+
if (!target?.package) fail(`${config.app} has no ${platform} package command`);
|
|
81
|
+
const requiredInputs = inputPaths(worktree, worktreeConfigPath, target);
|
|
82
|
+
const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
|
|
83
|
+
const inputHashes = hashInputs(requiredInputs);
|
|
84
|
+
const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
|
|
85
|
+
const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
|
|
86
|
+
const cacheKey = cacheFingerprint({
|
|
87
|
+
cargoLockSha256: cargoLock ? inputHashes[path.relative(worktree, cargoLock)] : "none",
|
|
88
|
+
rustc: toolVersions.rustc,
|
|
89
|
+
target: toolVersions.rustHost,
|
|
90
|
+
features: cargoToml ? sqlCipherFeatures(readFileSync(cargoToml, "utf8")) : [],
|
|
91
|
+
});
|
|
92
|
+
const env = {
|
|
93
|
+
...process.env,
|
|
94
|
+
...releaseEnvironment({ root: vaultRoot, platform, cacheKey, kind: "release" }),
|
|
95
|
+
};
|
|
96
|
+
if (!commandExists("sccache")) delete env.RUSTC_WRAPPER;
|
|
97
|
+
const releaseId = `${config.app}-${config.version}-${shortCommit}`;
|
|
98
|
+
const platformDir = platform === "win" ? "windows" : "mac";
|
|
99
|
+
const buildRoot = path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
|
|
100
|
+
const stateRoot = path.join(vaultRoot, "state", releaseId, platformDir);
|
|
101
|
+
const lockPayload = {
|
|
102
|
+
schema: 1,
|
|
103
|
+
releaseId,
|
|
104
|
+
app: config.app,
|
|
105
|
+
version: config.version,
|
|
106
|
+
commit,
|
|
107
|
+
platform,
|
|
108
|
+
cacheKey,
|
|
109
|
+
inputs: inputHashes,
|
|
110
|
+
tools: toolVersions,
|
|
111
|
+
createdAt: new Date().toISOString(),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const result = await runBuildStateMachine({
|
|
115
|
+
root: repoRoot,
|
|
116
|
+
app: config.app,
|
|
117
|
+
version: config.version,
|
|
118
|
+
commit,
|
|
119
|
+
platform,
|
|
120
|
+
requiredInputs,
|
|
121
|
+
ops: {
|
|
122
|
+
preflight: async () => {
|
|
123
|
+
assertDiskSpace(repoRoot, target.preflight?.minFreeGb ?? 20);
|
|
124
|
+
assertExecutables(["git", config.packageManager ?? "pnpm", "cargo", "rustc", ...(target.preflight?.executables ?? [])]);
|
|
125
|
+
for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
|
|
126
|
+
for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(worktree, command.cwd ?? "."), env);
|
|
127
|
+
mkdirSync(buildRoot, { recursive: true });
|
|
128
|
+
mkdirSync(stateRoot, { recursive: true });
|
|
129
|
+
writeJson(path.join(buildRoot, "release-inputs.lock.json"), lockPayload);
|
|
130
|
+
writeJson(path.join(stateRoot, "release-inputs.lock.json"), lockPayload);
|
|
131
|
+
checkpoint(stateRoot, "preflight_complete");
|
|
132
|
+
},
|
|
133
|
+
prepare: async () => {
|
|
134
|
+
const cacheTarget = env.CARGO_TARGET_DIR;
|
|
135
|
+
const targetLink = path.join(worktree, "src-tauri", "target");
|
|
136
|
+
mkdirSync(cacheTarget, { recursive: true });
|
|
137
|
+
if (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
|
|
138
|
+
else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
|
|
139
|
+
fail(`worktree target exists and is not the release cache link: ${targetLink}`);
|
|
140
|
+
}
|
|
141
|
+
await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], worktree, env, cacheTarget);
|
|
142
|
+
},
|
|
143
|
+
build: async () => {
|
|
144
|
+
await runProgress(process.execPath, [WORKER, "--config", worktreeConfigPath, "--platform", platform, "--no-upload"], worktree, env, env.CARGO_TARGET_DIR);
|
|
145
|
+
checkpoint(stateRoot, "build_complete");
|
|
146
|
+
checkpoint(stateRoot, "signed");
|
|
147
|
+
checkpoint(stateRoot, "hardened");
|
|
148
|
+
},
|
|
149
|
+
seal: async ({ sealedDir }) => {
|
|
150
|
+
sealRelease({ configRoot: path.dirname(worktreeConfigPath), sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey });
|
|
151
|
+
checkpoint(stateRoot, "sealed");
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
console.log(`right-release build: ${result.resumed ? "resumed" : "sealed"} ${result.releaseId}`);
|
|
156
|
+
console.log(`sealed: ${result.sealedDir}`);
|
|
157
|
+
} finally {
|
|
158
|
+
lock.release();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function inputPaths(worktree, configPath, target) {
|
|
162
|
+
const candidates = [
|
|
163
|
+
configPath,
|
|
164
|
+
path.join(worktree, "package.json"),
|
|
165
|
+
path.join(worktree, "pnpm-lock.yaml"),
|
|
166
|
+
path.join(worktree, "src-tauri", "Cargo.toml"),
|
|
167
|
+
path.join(worktree, "src-tauri", "Cargo.lock"),
|
|
168
|
+
...(target.preflight?.files ?? []).map((file) => path.resolve(worktree, expandEnv(file))),
|
|
169
|
+
];
|
|
170
|
+
const required = [...new Set(candidates)];
|
|
171
|
+
for (const file of required) if (!existsSync(file)) fail(`missing required release input: ${file}`);
|
|
172
|
+
return required;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hashInputs(files) {
|
|
176
|
+
return Object.fromEntries(files.map((file) => [path.relative(path.dirname(path.dirname(file)), file), hashFile(file)]));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function hashFile(file) {
|
|
180
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sqlCipherFeatures(text) {
|
|
184
|
+
return [...text.matchAll(/features\s*=\s*\[([^\]]+)\]/g)]
|
|
185
|
+
.flatMap((match) => match[1].match(/"([^"]+)"/g) ?? [])
|
|
186
|
+
.map((value) => value.slice(1, -1))
|
|
187
|
+
.filter((value) => /sqlcipher|openssl/i.test(value));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sealRelease({ configRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey }) {
|
|
191
|
+
const sources = new Map();
|
|
192
|
+
const addSource = (source, role) => {
|
|
193
|
+
const current = sources.get(source) ?? new Set();
|
|
194
|
+
current.add(role);
|
|
195
|
+
sources.set(source, current);
|
|
196
|
+
};
|
|
197
|
+
for (const artifact of target.installer?.artifacts ?? []) addSource(path.resolve(configRoot, artifact.file), "installer");
|
|
198
|
+
for (const artifact of target.updater?.artifacts ?? []) {
|
|
199
|
+
addSource(path.resolve(configRoot, artifact.file), "updater");
|
|
200
|
+
addSource(path.resolve(configRoot, artifact.signature), "updater-signature");
|
|
201
|
+
}
|
|
202
|
+
for (const file of sources.keys()) if (!existsSync(file)) fail(`cannot seal missing artifact: ${file}`);
|
|
203
|
+
const temp = `${sealedDir}.tmp-${process.pid}`;
|
|
204
|
+
if (existsSync(temp)) rmSync(temp, { recursive: true, force: true });
|
|
205
|
+
mkdirSync(temp, { recursive: true });
|
|
206
|
+
const files = [];
|
|
207
|
+
const sourceNames = new Map();
|
|
208
|
+
for (const [source, roles] of sources) {
|
|
209
|
+
let name = path.basename(source);
|
|
210
|
+
if ([...sourceNames.values()].includes(name) && sourceNames.get(source) !== name) name = `${[...roles].join("-")}-${name}`;
|
|
211
|
+
sourceNames.set(source, name);
|
|
212
|
+
copyFileSync(source, path.join(temp, name));
|
|
213
|
+
files.push({ role: [...roles].sort().join("+"), name, sha256: hashFile(source), sizeBytes: statSync(source).size });
|
|
214
|
+
}
|
|
215
|
+
const routes = { patch: [], update: [] };
|
|
216
|
+
for (const artifact of target.installer?.artifacts ?? []) {
|
|
217
|
+
const source = path.resolve(configRoot, artifact.file);
|
|
218
|
+
routes.patch.push({ role: "installer", name: sourceNames.get(source), bucket: "public", key: artifact.key });
|
|
219
|
+
}
|
|
220
|
+
for (const artifact of target.updater?.artifacts ?? []) {
|
|
221
|
+
const source = path.resolve(configRoot, artifact.file);
|
|
222
|
+
const patchKey = artifact.patchKey ?? artifact.key.replace("/updates/", "/installers/");
|
|
223
|
+
routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(path.resolve(configRoot, artifact.signature)), bucket: "public", key: patchKey, platform: artifact.platform });
|
|
224
|
+
routes.update.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(path.resolve(configRoot, artifact.signature)), bucket: "private", key: artifact.key, platform: artifact.platform });
|
|
225
|
+
}
|
|
226
|
+
writeJson(path.join(temp, "hardening.json"), { schema: 1, passed: true, checkedAt: new Date().toISOString(), files: files.map((file) => file.sha256) });
|
|
227
|
+
const manifest = {
|
|
228
|
+
schema: 1,
|
|
229
|
+
releaseId,
|
|
230
|
+
app: config.app,
|
|
231
|
+
version: config.version,
|
|
232
|
+
commit,
|
|
233
|
+
platform,
|
|
234
|
+
cacheKey,
|
|
235
|
+
files,
|
|
236
|
+
routes,
|
|
237
|
+
inputs: inputHashes,
|
|
238
|
+
tools: toolVersions,
|
|
239
|
+
checkpoints: ["preflight_complete", "build_complete", "signed", "hardened", "sealed"],
|
|
240
|
+
sealedAt: new Date().toISOString(),
|
|
241
|
+
};
|
|
242
|
+
writeJson(path.join(temp, "release-manifest.json"), manifest);
|
|
243
|
+
if (existsSync(sealedDir)) fail(`sealed directory already exists: ${sealedDir}`);
|
|
244
|
+
mkdirSync(path.dirname(sealedDir), { recursive: true });
|
|
245
|
+
renameSync(temp, sealedDir);
|
|
246
|
+
for (const item of files) {
|
|
247
|
+
const file = path.join(sealedDir, item.name);
|
|
248
|
+
try { chmodSync(file, 0o444); } catch { /* best effort; hashes are authoritative */ }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function collectToolVersions(packageManager) {
|
|
253
|
+
const rustcVerbose = commandOutput("rustc", ["-vV"]);
|
|
254
|
+
return {
|
|
255
|
+
node: process.version,
|
|
256
|
+
packageManager: `${packageManager} ${commandOutput(packageManager, ["--version"])}`,
|
|
257
|
+
cargo: commandOutput("cargo", ["--version"]),
|
|
258
|
+
rustc: rustcVerbose,
|
|
259
|
+
rustHost: rustcVerbose.match(/^host:\s*(.+)$/m)?.[1] ?? "unknown",
|
|
260
|
+
sccache: commandExists("sccache") ? commandOutput("sccache", ["--version"]) : null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function runProgress(cmd, runArgs, cwd, env, watchDir) {
|
|
265
|
+
const inactivityMs = Number(process.env.RIGHT_RELEASE_STALL_MS ?? 10 * 60 * 1000);
|
|
266
|
+
const absoluteMs = Number(process.env.RIGHT_RELEASE_ABSOLUTE_MS ?? 90 * 60 * 1000);
|
|
267
|
+
let lastProgress = Date.now();
|
|
268
|
+
let lastMtime = newestMtime(watchDir);
|
|
269
|
+
const started = Date.now();
|
|
270
|
+
await new Promise((resolve, reject) => {
|
|
271
|
+
child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32" });
|
|
272
|
+
for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
|
|
273
|
+
stream.on("data", (chunk) => { lastProgress = Date.now(); output.write(chunk); });
|
|
274
|
+
}
|
|
275
|
+
const monitor = setInterval(() => {
|
|
276
|
+
const mtime = newestMtime(watchDir);
|
|
277
|
+
if (mtime > lastMtime) { lastMtime = mtime; lastProgress = Date.now(); }
|
|
278
|
+
if (Date.now() - started > absoluteMs) stop(`absolute limit exceeded (${Math.round(absoluteMs / 60000)}m)`);
|
|
279
|
+
else if (Date.now() - lastProgress > inactivityMs) stop(`no output or file progress for ${Math.round(inactivityMs / 60000)}m`);
|
|
280
|
+
}, Math.min(30_000, Math.max(250, Math.floor(inactivityMs / 4))));
|
|
281
|
+
const stop = (reason) => { clearInterval(monitor); killTree(child.pid); reject(new Error(`release step stalled: ${reason}`)); };
|
|
282
|
+
child.once("error", (error) => { clearInterval(monitor); reject(error); });
|
|
283
|
+
child.once("exit", (code) => { clearInterval(monitor); child = null; code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`)); });
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function newestMtime(dir) {
|
|
288
|
+
if (!existsSync(dir)) return 0;
|
|
289
|
+
try { return statSync(dir).mtimeMs; } catch { return 0; }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function checkpoint(stateRoot, name) {
|
|
293
|
+
const file = path.join(stateRoot, "checkpoints.json");
|
|
294
|
+
const state = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { schema: 1, completed: [] };
|
|
295
|
+
if (!state.completed.includes(name)) state.completed.push(name);
|
|
296
|
+
state.updatedAt = new Date().toISOString();
|
|
297
|
+
writeJson(file, state);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function acquireLock(file) {
|
|
301
|
+
if (existsSync(file)) {
|
|
302
|
+
try {
|
|
303
|
+
const existing = JSON.parse(readFileSync(file, "utf8"));
|
|
304
|
+
if (!processAlive(existing.pid)) unlinkSync(file);
|
|
305
|
+
} catch {
|
|
306
|
+
unlinkSync(file);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const fd = openSync(file, "wx");
|
|
311
|
+
writeFileSync(fd, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), argv: process.argv })}\n`);
|
|
312
|
+
closeSync(fd);
|
|
313
|
+
return { release: () => { try { unlinkSync(file); } catch { /* already released */ } } };
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error.code === "EEXIST") fail(`another ${platform} release is active: ${file}`);
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function processAlive(pid) {
|
|
321
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
|
|
322
|
+
try { process.kill(Number(pid), 0); return true; } catch { return false; }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function assertDiskSpace(dir, minimumGb) {
|
|
326
|
+
const stats = statfsSync(dir);
|
|
327
|
+
const freeGb = Number(stats.bavail * stats.bsize) / 1024 ** 3;
|
|
328
|
+
if (freeGb < minimumGb) fail(`insufficient disk space: ${freeGb.toFixed(1)} GiB free, ${minimumGb} GiB required`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function assertExecutables(names) {
|
|
332
|
+
for (const name of names) if (!commandExists(name)) fail(`required executable not found: ${name}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function commandExists(name) {
|
|
336
|
+
const probe = process.platform === "win32" ? spawnSync("where.exe", [name], { stdio: "ignore", windowsHide: true }) : spawnSync("sh", ["-lc", `command -v '${name.replaceAll("'", "")}'`], { stdio: "ignore" });
|
|
337
|
+
return probe.status === 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function commandOutput(cmd, runArgs) {
|
|
341
|
+
const result = spawnSync(cmd, runArgs, { encoding: "utf8", windowsHide: true });
|
|
342
|
+
if (result.status !== 0) fail(`required command failed: ${cmd} ${runArgs.join(" ")}`);
|
|
343
|
+
return result.stdout.trim();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function git(cwd, runArgs) {
|
|
347
|
+
return commandOutputAt("git", runArgs, cwd);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function commandOutputAt(cmd, runArgs, cwd) {
|
|
351
|
+
const result = spawnSync(cmd, runArgs, { cwd, encoding: "utf8", windowsHide: true });
|
|
352
|
+
if (result.status !== 0) fail(`${cmd} ${runArgs.join(" ")} failed: ${(result.stderr ?? "").trim()}`);
|
|
353
|
+
return result.stdout.trim();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function runChecked(cmd, runArgs, cwd, env = process.env) {
|
|
357
|
+
const result = spawnSync(cmd, runArgs, { cwd, env, stdio: "inherit", windowsHide: true, shell: false });
|
|
358
|
+
if (result.status !== 0) fail(`${cmd} exited ${result.status}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function killTree(pid) {
|
|
362
|
+
if (!pid) return;
|
|
363
|
+
if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
364
|
+
else { try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } } }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function expandEnv(value) {
|
|
368
|
+
return String(value).replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "").replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? "");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function writeJson(file, value) {
|
|
372
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
373
|
+
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function usage(code) {
|
|
377
|
+
console.log("usage: right-release build --platform win|mac [--config right-release.config.mjs] [--dry-run]");
|
|
378
|
+
process.exit(code);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function fail(message) {
|
|
382
|
+
console.error(`right-release build: ${message}`);
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
package/cli/right-release.mjs
CHANGED
|
@@ -5,9 +5,11 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
|
|
6
6
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
7
|
const commands = new Map([
|
|
8
|
-
["release", "release.mjs"],
|
|
9
|
-
["build", "release.mjs"],
|
|
10
|
-
["package", "release.mjs"],
|
|
8
|
+
["release", "build-release.mjs"],
|
|
9
|
+
["build", "build-release.mjs"],
|
|
10
|
+
["package", "build-release.mjs"],
|
|
11
|
+
["publish", "upload-release.mjs"],
|
|
12
|
+
["upload", "upload-release.mjs"],
|
|
11
13
|
["deps", "deps.mjs"],
|
|
12
14
|
["publish-update", "publish-update.mjs"],
|
|
13
15
|
["hardening", "hardeningscan.mjs"],
|
|
@@ -47,7 +49,7 @@ if (first === "--version" || first === "-v") {
|
|
|
47
49
|
} else if (rest[0] === "swift") {
|
|
48
50
|
run("publish-swift.mjs", rest.slice(1));
|
|
49
51
|
} else {
|
|
50
|
-
run("release.mjs", rest
|
|
52
|
+
run("upload-release.mjs", rest);
|
|
51
53
|
}
|
|
52
54
|
} else if (first === "model") {
|
|
53
55
|
const rest = args.slice(1);
|
|
@@ -63,7 +65,7 @@ if (first === "--version" || first === "-v") {
|
|
|
63
65
|
} else if (commands.has(first)) {
|
|
64
66
|
run(commands.get(first), args.slice(1));
|
|
65
67
|
} else if (first.startsWith("-")) {
|
|
66
|
-
run("release.mjs", args);
|
|
68
|
+
run("build-release.mjs", args);
|
|
67
69
|
} else {
|
|
68
70
|
console.error(`right-release: unknown command: ${first}\n`);
|
|
69
71
|
printHelp();
|
|
@@ -119,8 +121,11 @@ function printHelp() {
|
|
|
119
121
|
console.log(`right-release <command> [options]
|
|
120
122
|
|
|
121
123
|
Commands:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
build [--platform mac|win] Build, sign/notarize, harden, and seal; never uploads
|
|
125
|
+
upload --release <id> --platform mac|win --tier patch|update
|
|
126
|
+
Upload one sealed release; never builds
|
|
127
|
+
release Compatibility alias for build
|
|
128
|
+
publish Compatibility alias for upload
|
|
124
129
|
publish cargo --crate <name> [--dry-run] Test, inspect, scan, and publish one crates.io package
|
|
125
130
|
publish swift [--scope <s>] [--url <u>] [--dry-run] Test, archive, scan, and publish RightKitSwift to a Swift registry
|
|
126
131
|
model promote --authority heardright --config <file> [--dry-run]
|
|
@@ -135,6 +140,6 @@ Commands:
|
|
|
135
140
|
mirror-root-artifact --file <path> --package-root <dir>
|
|
136
141
|
Persist a worktree artifact in the primary repo root
|
|
137
142
|
|
|
138
|
-
Direct flags are treated as: right-release
|
|
139
|
-
|
|
143
|
+
Direct flags are treated as: right-release build <flags>.
|
|
144
|
+
Build is tier-neutral. Upload requires an explicit tier. Unsigned/local smoke builds stay app-local.`);
|
|
140
145
|
}
|
package/legal-contract.mjs
CHANGED
|
@@ -135,8 +135,15 @@ export function assertLegalReleaseContract(root, legal, appName, platform) {
|
|
|
135
135
|
appendices.push({ ...appendix, absolutePath: realFile });
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
// The in-app gate is the
|
|
139
|
-
//
|
|
138
|
+
// The in-app gate is the SINGLE assent surface (Adrian, 2026-07-17). An
|
|
139
|
+
// installer/DMG can only PRESENT the text: it cannot record who agreed, cannot ask
|
|
140
|
+
// the individual-vs-enterprise basis, and passive updaters reach neither — so a
|
|
141
|
+
// license page is duplicate presentation that captures nothing. This gate
|
|
142
|
+
// previously REQUIRED bundle.licenseFile; it now requires its absence.
|
|
143
|
+
//
|
|
144
|
+
// Checked on EVERY platform, not just Windows: one bundle.licenseFile drives BOTH
|
|
145
|
+
// the NSIS license page and the macOS DMG SLA (branded-dmg.mjs derives its --eula
|
|
146
|
+
// from it), so a win-only check would let a mac build reintroduce the SLA.
|
|
140
147
|
if (typeof legal.tauriConfig !== "string") fail(appName, "the legal gate requires legal.tauriConfig");
|
|
141
148
|
const tauriPath = path.resolve(appRoot, legal.tauriConfig);
|
|
142
149
|
if (!within(appRoot, tauriPath) || !existsSync(tauriPath)) fail(appName, `missing Tauri config: ${legal.tauriConfig}`);
|
package/legal-contract.test.mjs
CHANGED
|
@@ -46,6 +46,8 @@ function fixture() {
|
|
|
46
46
|
})),
|
|
47
47
|
};
|
|
48
48
|
writeFileSync(path.join(legalDir, "legal-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
49
|
+
// No bundle.licenseFile: the in-app gate is the single assent surface, so a
|
|
50
|
+
// compliant app ships an installer with no license page (Adrian, 2026-07-17).
|
|
49
51
|
writeFileSync(
|
|
50
52
|
path.join(tauriDir, "tauri.conf.json"),
|
|
51
53
|
`${JSON.stringify({ bundle: { publisher: "Damned Ventures LLC" } }, null, 2)}\n`,
|
|
@@ -105,6 +107,8 @@ test("rejects a Windows installer license page: the in-app gate is the only asse
|
|
|
105
107
|
});
|
|
106
108
|
|
|
107
109
|
test("rejects a macOS DMG SLA too — one licenseFile drives both bundlers", () => {
|
|
110
|
+
// branded-dmg.mjs derives its --eula from bundle.licenseFile, so a Windows-only
|
|
111
|
+
// check would let a mac release quietly reintroduce the mount-time SLA.
|
|
108
112
|
const { root, legal } = fixture();
|
|
109
113
|
writeFileSync(path.join(root, "src-tauri", "tauri.conf.json"), JSON.stringify({ bundle: { licenseFile: "../legal/EULA.md" } }));
|
|
110
114
|
assert.throws(() => assertLegalReleaseContract(root, legal, "fixture", "mac"), /licenseFile must be absent/i);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.32",
|
|
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": {
|
package/pub-upload.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { accessSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const ACCOUNT_ID = "03ae77ccd7a07bcbb2dcfde47fa7ba3a";
|
|
7
|
+
const BUCKETS = new Map([
|
|
8
|
+
["public", "rightapps-downloads"],
|
|
9
|
+
["private", "rightapps-updates"],
|
|
10
|
+
]);
|
|
11
|
+
// Wrangler always runs remotely through a package runner; which one exists is machine-specific
|
|
12
|
+
// (a node install without npm/npx is normal when pnpm is the pinned package manager).
|
|
13
|
+
const RUNNERS = [
|
|
14
|
+
{ cmd: "npx", prefix: [] },
|
|
15
|
+
{ cmd: "pnpm", prefix: ["dlx"] },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const [, , localFile, r2Key, bucketAlias = "public"] = process.argv;
|
|
19
|
+
if (!localFile || !r2Key) {
|
|
20
|
+
console.error("Usage: node upload-large.mjs <localFile> <r2Key> [public|private]");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const bucket = BUCKETS.get(bucketAlias);
|
|
25
|
+
if (!bucket) {
|
|
26
|
+
console.error(`upload-large: invalid bucket alias ${bucketAlias}; expected public|private`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
accessSync(localFile);
|
|
32
|
+
} catch {
|
|
33
|
+
console.error(`upload-large: missing local file: ${localFile}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const fileSize = statSync(localFile).size;
|
|
38
|
+
const object = `${bucket}/${r2Key}`;
|
|
39
|
+
const wranglerArgs = ["wrangler@4", "r2", "object", "put", object, "--file", path.resolve(localFile), "--remote"];
|
|
40
|
+
const env = cleanCloudflareEnv(process.env);
|
|
41
|
+
|
|
42
|
+
console.log(`Uploading ${path.basename(localFile)} (${(fileSize / 1024 / 1024).toFixed(1)} MB) -> ${object}`);
|
|
43
|
+
console.log(`wrangler r2 object put ${object} --file ${path.resolve(localFile)} --remote`);
|
|
44
|
+
|
|
45
|
+
// Resolve the package runner before the dry-run exit: a release box without one cannot upload,
|
|
46
|
+
// and that must fail here rather than after a signed build has already been produced.
|
|
47
|
+
const runner = resolveRunner(env);
|
|
48
|
+
if (!runner) {
|
|
49
|
+
console.error(
|
|
50
|
+
"upload-large: no package runner found. Tried: " +
|
|
51
|
+
`${RUNNERS.map((entry) => [entry.cmd, ...entry.prefix].join(" ")).join(", ")}. ` +
|
|
52
|
+
"Install one, or set RIGHT_RELEASE_WRANGLER_RUNNER (e.g. \"pnpm dlx\").",
|
|
53
|
+
);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
console.log(`runner: ${[runner.cmd, ...runner.prefix].join(" ")}`);
|
|
57
|
+
|
|
58
|
+
if (process.env.RIGHT_RELEASE_UPLOAD_DRY_RUN === "1") {
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = spawnSync(runner.cmd, [...runner.prefix, ...wranglerArgs], {
|
|
63
|
+
env,
|
|
64
|
+
stdio: "inherit",
|
|
65
|
+
shell: process.platform === "win32",
|
|
66
|
+
windowsHide: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (result.error) {
|
|
70
|
+
console.error(`upload-large: failed to start wrangler: ${result.error.message}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
process.exit(result.status ?? 1);
|
|
74
|
+
|
|
75
|
+
function resolveRunner(env) {
|
|
76
|
+
const override = process.env.RIGHT_RELEASE_WRANGLER_RUNNER?.trim();
|
|
77
|
+
if (override) {
|
|
78
|
+
const [cmd, ...prefix] = override.split(/\s+/);
|
|
79
|
+
return { cmd, prefix };
|
|
80
|
+
}
|
|
81
|
+
for (const candidate of RUNNERS) {
|
|
82
|
+
const probe = spawnSync(candidate.cmd, ["--version"], {
|
|
83
|
+
env,
|
|
84
|
+
stdio: "ignore",
|
|
85
|
+
shell: process.platform === "win32",
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
});
|
|
88
|
+
if (!probe.error && probe.status === 0) return candidate;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanCloudflareEnv(source) {
|
|
94
|
+
const env = { ...source, CLOUDFLARE_ACCOUNT_ID: source.CLOUDFLARE_ACCOUNT_ID || ACCOUNT_ID };
|
|
95
|
+
delete env.CLOUDFLARE_API_KEY;
|
|
96
|
+
delete env.CLOUDFLARE_EMAIL;
|
|
97
|
+
delete env.CF_API_KEY;
|
|
98
|
+
delete env.CF_EMAIL;
|
|
99
|
+
return env;
|
|
100
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const cli = path.join(root, "cli", "right-release.mjs");
|
|
11
|
+
const build = path.join(root, "build-release.mjs");
|
|
12
|
+
const upload = path.join(root, "upload-release.mjs");
|
|
13
|
+
|
|
14
|
+
test("CLI routes build and release only to the build state machine", () => {
|
|
15
|
+
const source = readFileSync(cli, "utf8");
|
|
16
|
+
assert.match(source, /\["build",\s*"build-release\.mjs"\]/);
|
|
17
|
+
assert.match(source, /\["release",\s*"build-release\.mjs"\]/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("CLI routes upload and legacy publish only to the upload state machine", () => {
|
|
21
|
+
const source = readFileSync(cli, "utf8");
|
|
22
|
+
assert.match(source, /\["upload",\s*"upload-release\.mjs"\]/);
|
|
23
|
+
assert.match(source, /\["publish",\s*"upload-release\.mjs"\]/);
|
|
24
|
+
assert.doesNotMatch(source, /first === "publish"[\s\S]{0,500}run\("release\.mjs"/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("build is tier-neutral and rejects a tier before touching a repository", () => {
|
|
28
|
+
const result = spawnSync(process.execPath, [build, "--platform", "win", "--tier", "patch"], {
|
|
29
|
+
cwd: mkdtempSync(path.join(os.tmpdir(), "right-build-cli-")),
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
});
|
|
32
|
+
assert.notEqual(result.status, 0);
|
|
33
|
+
assert.match(result.stderr, /build is tier-neutral/i);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("upload requires an explicit patch or update tier before reading a release", () => {
|
|
37
|
+
const result = spawnSync(process.execPath, [upload, "--platform", "win", "--release", "fixture-1.0.0-deadbeef"], {
|
|
38
|
+
cwd: mkdtempSync(path.join(os.tmpdir(), "right-upload-cli-")),
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
});
|
|
41
|
+
assert.notEqual(result.status, 0);
|
|
42
|
+
assert.match(result.stderr, /tier is required.*patch\|update/i);
|
|
43
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export function cacheFingerprint({ cargoLockSha256, rustc, target, features = [] }) {
|
|
6
|
+
const payload = JSON.stringify({
|
|
7
|
+
cargoLockSha256,
|
|
8
|
+
rustc,
|
|
9
|
+
target,
|
|
10
|
+
features: [...features].sort(),
|
|
11
|
+
});
|
|
12
|
+
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
|
13
|
+
}
|
|
14
|
+
export function releaseEnvironment({ root, platform, cacheKey, kind = "release" }) {
|
|
15
|
+
if (kind !== "release" && kind !== "test") throw new Error(`invalid target kind: ${kind}`);
|
|
16
|
+
const targetKind = kind === "release" ? "cargo-target" : "test-target";
|
|
17
|
+
return {
|
|
18
|
+
CARGO_TARGET_DIR: path.resolve(root, "cache", targetKind, platform, cacheKey),
|
|
19
|
+
CARGO_HOME: path.resolve(root, "cache", "cargo-home"),
|
|
20
|
+
SCCACHE_DIR: path.resolve(root, "cache", "sccache"),
|
|
21
|
+
RUSTC_WRAPPER: "sccache",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function verifySealedRelease(sealedDir) {
|
|
26
|
+
const manifestPath = path.join(sealedDir, "release-manifest.json");
|
|
27
|
+
if (!existsSync(manifestPath)) throw new Error(`sealed manifest missing: ${manifestPath}`);
|
|
28
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
29
|
+
if (manifest.schema !== 1) throw new Error(`unsupported sealed manifest schema: ${manifest.schema}`);
|
|
30
|
+
if (!manifest.checkpoints?.includes("sealed")) throw new Error("release is not sealed");
|
|
31
|
+
for (const item of manifest.files ?? []) {
|
|
32
|
+
const file = path.join(sealedDir, item.name);
|
|
33
|
+
if (!existsSync(file)) throw new Error(`sealed file missing: ${item.name}`);
|
|
34
|
+
const bytes = readFileSync(file);
|
|
35
|
+
const actualHash = createHash("sha256").update(bytes).digest("hex");
|
|
36
|
+
if (actualHash !== item.sha256) throw new Error(`sealed file hash mismatch: ${item.name}`);
|
|
37
|
+
if (bytes.length !== item.sizeBytes) throw new Error(`sealed file size mismatch: ${item.name}`);
|
|
38
|
+
}
|
|
39
|
+
return { manifest, manifestPath, sealedDir };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runBuildStateMachine({
|
|
43
|
+
root,
|
|
44
|
+
app,
|
|
45
|
+
version,
|
|
46
|
+
commit,
|
|
47
|
+
platform,
|
|
48
|
+
requiredInputs = [],
|
|
49
|
+
ops,
|
|
50
|
+
}) {
|
|
51
|
+
const releaseId = `${app}-${version}-${commit.slice(0, 8)}`;
|
|
52
|
+
const platformDir = platform === "win" ? "windows" : platform === "mac" ? "mac" : platform;
|
|
53
|
+
const sealedDir = path.join(root, ".right-release", "sealed", releaseId, platformDir);
|
|
54
|
+
if (existsSync(path.join(sealedDir, "release-manifest.json"))) {
|
|
55
|
+
const sealed = verifySealedRelease(sealedDir);
|
|
56
|
+
if (sealed.manifest.app !== app || sealed.manifest.version !== version || sealed.manifest.commit !== commit) {
|
|
57
|
+
throw new Error(`sealed release identity mismatch: ${releaseId}`);
|
|
58
|
+
}
|
|
59
|
+
return { status: "sealed", resumed: true, releaseId, sealedDir };
|
|
60
|
+
}
|
|
61
|
+
await ops.preflight?.({ root, app, version, commit, platform, requiredInputs, releaseId });
|
|
62
|
+
await ops.prepare?.({ root, app, version, commit, platform, releaseId });
|
|
63
|
+
await ops.build?.({ root, app, version, commit, platform, releaseId });
|
|
64
|
+
await ops.sign?.({ root, app, version, commit, platform, releaseId });
|
|
65
|
+
await ops.harden?.({ root, app, version, commit, platform, releaseId });
|
|
66
|
+
await ops.seal?.({ root, app, version, commit, platform, releaseId, sealedDir });
|
|
67
|
+
return { status: "sealed", resumed: false, releaseId, sealedDir };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function runUploadStateMachine({ root, releaseId, platform, tier, ops = {} }) {
|
|
71
|
+
if (tier !== "patch" && tier !== "update") throw new Error("upload tier is required (patch|update)");
|
|
72
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/.test(String(releaseId))) throw new Error(`invalid release id: ${releaseId}`);
|
|
73
|
+
const platformDir = platform === "win" ? "windows" : platform === "mac" ? "mac" : platform;
|
|
74
|
+
const sealedDir = path.join(root, ".right-release", "sealed", releaseId, platformDir);
|
|
75
|
+
const sealed = verifySealedRelease(sealedDir);
|
|
76
|
+
if (sealed.manifest.platform !== platform) throw new Error("sealed release platform mismatch");
|
|
77
|
+
const routes = sealed.manifest.routes?.[tier] ?? [];
|
|
78
|
+
if (!routes.length) throw new Error(`sealed release has no ${tier} upload route`);
|
|
79
|
+
validateUploadRoutes(routes, tier, platformDir);
|
|
80
|
+
|
|
81
|
+
await ops.verifyAuthenticode?.(sealed);
|
|
82
|
+
await ops.harden?.(sealed);
|
|
83
|
+
let backedUp = false;
|
|
84
|
+
try {
|
|
85
|
+
await ops.backup?.(sealed, routes);
|
|
86
|
+
backedUp = true;
|
|
87
|
+
await ops.upload?.(sealed, routes);
|
|
88
|
+
await ops.register?.(sealed, routes);
|
|
89
|
+
await ops.verifyRemote?.(sealed, routes);
|
|
90
|
+
await ops.discardBackup?.(sealed, routes);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (backedUp) await ops.restore?.(sealed, routes);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
return { status: "verified", releaseId, tier, sealedDir };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateUploadRoutes(routes, tier, platformDir) {
|
|
99
|
+
const expectedBucket = tier === "patch" ? "public" : "private";
|
|
100
|
+
const expectedLane = tier === "patch" ? "installers" : "updates";
|
|
101
|
+
const marker = `/${expectedLane}/${platformDir}/current/`;
|
|
102
|
+
for (const route of routes) {
|
|
103
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(String(route.key)) || String(route.key).includes("//")) {
|
|
104
|
+
throw new Error(`unsafe R2 key: ${route.key}`);
|
|
105
|
+
}
|
|
106
|
+
if (route.bucket !== expectedBucket || !String(route.key).includes(marker)) {
|
|
107
|
+
throw new Error(`${tier} route must use ${expectedBucket} ${expectedLane}/${platformDir}/current: ${route.key}`);
|
|
108
|
+
}
|
|
109
|
+
if (String(route.key).includes("..")) throw new Error(`${tier} route contains traversal: ${route.key}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
cacheFingerprint,
|
|
10
|
+
releaseEnvironment,
|
|
11
|
+
runBuildStateMachine,
|
|
12
|
+
runUploadStateMachine,
|
|
13
|
+
verifySealedRelease,
|
|
14
|
+
} from "./release-state.mjs";
|
|
15
|
+
|
|
16
|
+
function sha256(value) {
|
|
17
|
+
return createHash("sha256").update(value).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fixture() {
|
|
21
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "right-release-state-"));
|
|
22
|
+
const sealedDir = path.join(root, ".right-release", "sealed", "fixture-1.2.3-abcdef12", "windows");
|
|
23
|
+
mkdirSync(sealedDir, { recursive: true });
|
|
24
|
+
const installer = path.join(sealedDir, "Fixture_x64-setup.exe");
|
|
25
|
+
const signature = `${installer}.sig`;
|
|
26
|
+
writeFileSync(installer, "signed-installer");
|
|
27
|
+
writeFileSync(signature, "updater-signature");
|
|
28
|
+
const manifest = {
|
|
29
|
+
schema: 1,
|
|
30
|
+
releaseId: "fixture-1.2.3-abcdef12",
|
|
31
|
+
app: "fixture",
|
|
32
|
+
version: "1.2.3",
|
|
33
|
+
commit: "abcdef1234567890",
|
|
34
|
+
platform: "win",
|
|
35
|
+
files: [
|
|
36
|
+
{ role: "installer", name: path.basename(installer), sha256: sha256("signed-installer"), sizeBytes: 16 },
|
|
37
|
+
{ role: "updater-signature", name: path.basename(signature), sha256: sha256("updater-signature"), sizeBytes: 17 },
|
|
38
|
+
],
|
|
39
|
+
routes: {
|
|
40
|
+
patch: [{ role: "installer", bucket: "public", key: "fixture/installers/windows/current/Fixture_x64-setup.exe" }],
|
|
41
|
+
update: [{ role: "installer", bucket: "private", key: "fixture/updates/windows/current/Fixture_x64-setup.exe" }],
|
|
42
|
+
},
|
|
43
|
+
checkpoints: ["preflight_complete", "build_complete", "signed", "hardened", "sealed"],
|
|
44
|
+
};
|
|
45
|
+
writeFileSync(path.join(sealedDir, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
46
|
+
return { root, sealedDir, installer, signature, manifest };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("upload has no build operation and restores the previous stable object when registration fails", async () => {
|
|
50
|
+
const fx = fixture();
|
|
51
|
+
const calls = [];
|
|
52
|
+
await assert.rejects(
|
|
53
|
+
runUploadStateMachine({
|
|
54
|
+
root: fx.root,
|
|
55
|
+
releaseId: fx.manifest.releaseId,
|
|
56
|
+
platform: "win",
|
|
57
|
+
tier: "patch",
|
|
58
|
+
ops: {
|
|
59
|
+
verifyAuthenticode: async () => calls.push("verify-authenticode"),
|
|
60
|
+
harden: async () => calls.push("harden"),
|
|
61
|
+
backup: async () => calls.push("backup"),
|
|
62
|
+
upload: async () => calls.push("upload"),
|
|
63
|
+
register: async () => { calls.push("register"); throw new Error("R2/API unavailable"); },
|
|
64
|
+
verifyRemote: async () => calls.push("verify-remote"),
|
|
65
|
+
restore: async () => calls.push("restore"),
|
|
66
|
+
discardBackup: async () => calls.push("discard-backup"),
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
/R2\/API unavailable/,
|
|
70
|
+
);
|
|
71
|
+
assert.deepEqual(calls, ["verify-authenticode", "harden", "backup", "upload", "register", "restore"]);
|
|
72
|
+
});
|
|
73
|
+
test("missing runtime input fails before build preparation and cannot touch an existing sealed installer", async () => {
|
|
74
|
+
const fx = fixture();
|
|
75
|
+
let prepared = false;
|
|
76
|
+
await assert.rejects(
|
|
77
|
+
runBuildStateMachine({
|
|
78
|
+
root: fx.root,
|
|
79
|
+
app: "fixture",
|
|
80
|
+
version: "1.2.4",
|
|
81
|
+
commit: "1234567890abcdef",
|
|
82
|
+
platform: "win",
|
|
83
|
+
requiredInputs: [path.join(fx.root, "missing-directml.dll")],
|
|
84
|
+
ops: {
|
|
85
|
+
preflight: async ({ requiredInputs }) => {
|
|
86
|
+
for (const file of requiredInputs) readFileSync(file);
|
|
87
|
+
},
|
|
88
|
+
prepare: async () => { prepared = true; },
|
|
89
|
+
build: async () => {},
|
|
90
|
+
sign: async () => {},
|
|
91
|
+
harden: async () => {},
|
|
92
|
+
seal: async () => {},
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
/ENOENT/,
|
|
96
|
+
);
|
|
97
|
+
assert.equal(prepared, false);
|
|
98
|
+
assert.equal(readFileSync(fx.installer, "utf8"), "signed-installer");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("an interrupted build leaves every previously sealed release untouched", async () => {
|
|
102
|
+
const fx = fixture();
|
|
103
|
+
await assert.rejects(
|
|
104
|
+
runBuildStateMachine({
|
|
105
|
+
root: fx.root,
|
|
106
|
+
app: "fixture",
|
|
107
|
+
version: "1.2.4",
|
|
108
|
+
commit: "1234567890abcdef",
|
|
109
|
+
platform: "win",
|
|
110
|
+
requiredInputs: [],
|
|
111
|
+
ops: {
|
|
112
|
+
preflight: async () => {},
|
|
113
|
+
prepare: async () => {},
|
|
114
|
+
build: async () => { throw new Error("SIGINT"); },
|
|
115
|
+
sign: async () => {},
|
|
116
|
+
harden: async () => {},
|
|
117
|
+
seal: async () => {},
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
/SIGINT/,
|
|
121
|
+
);
|
|
122
|
+
assert.equal(readFileSync(fx.installer, "utf8"), "signed-installer");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a valid sealed release resumes without rebuilding", async () => {
|
|
126
|
+
const fx = fixture();
|
|
127
|
+
let builds = 0;
|
|
128
|
+
const result = await runBuildStateMachine({
|
|
129
|
+
root: fx.root,
|
|
130
|
+
app: "fixture",
|
|
131
|
+
version: fx.manifest.version,
|
|
132
|
+
commit: fx.manifest.commit,
|
|
133
|
+
platform: "win",
|
|
134
|
+
requiredInputs: [],
|
|
135
|
+
ops: {
|
|
136
|
+
preflight: async () => {},
|
|
137
|
+
prepare: async () => {},
|
|
138
|
+
build: async () => { builds += 1; },
|
|
139
|
+
sign: async () => {},
|
|
140
|
+
harden: async () => {},
|
|
141
|
+
seal: async () => {},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
assert.equal(result.status, "sealed");
|
|
145
|
+
assert.equal(result.resumed, true);
|
|
146
|
+
assert.equal(builds, 0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("manifest or file tampering blocks upload before any mutation", async () => {
|
|
150
|
+
const fx = fixture();
|
|
151
|
+
writeFileSync(fx.installer, "changed");
|
|
152
|
+
assert.throws(() => verifySealedRelease(fx.sealedDir), /hash mismatch/);
|
|
153
|
+
let uploaded = false;
|
|
154
|
+
await assert.rejects(
|
|
155
|
+
runUploadStateMachine({
|
|
156
|
+
root: fx.root,
|
|
157
|
+
releaseId: fx.manifest.releaseId,
|
|
158
|
+
platform: "win",
|
|
159
|
+
tier: "patch",
|
|
160
|
+
ops: {
|
|
161
|
+
upload: async () => { uploaded = true; },
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
/hash mismatch/,
|
|
165
|
+
);
|
|
166
|
+
assert.equal(uploaded, false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("tier or non-current route mismatch fails before R2 backup", async () => {
|
|
170
|
+
const fx = fixture();
|
|
171
|
+
fx.manifest.routes.patch[0].bucket = "private";
|
|
172
|
+
fx.manifest.routes.patch[0].key = "fixture/updates/windows/1.2.3/Fixture.exe";
|
|
173
|
+
writeFileSync(path.join(fx.sealedDir, "release-manifest.json"), `${JSON.stringify(fx.manifest, null, 2)}\n`);
|
|
174
|
+
let backedUp = false;
|
|
175
|
+
await assert.rejects(
|
|
176
|
+
runUploadStateMachine({
|
|
177
|
+
root: fx.root,
|
|
178
|
+
releaseId: fx.manifest.releaseId,
|
|
179
|
+
platform: "win",
|
|
180
|
+
tier: "patch",
|
|
181
|
+
ops: { backup: async () => { backedUp = true; } },
|
|
182
|
+
}),
|
|
183
|
+
/patch route.*public.*installers.*current/i,
|
|
184
|
+
);
|
|
185
|
+
assert.equal(backedUp, false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("release ids and R2 keys reject traversal and shell metacharacters", async () => {
|
|
189
|
+
const fx = fixture();
|
|
190
|
+
await assert.rejects(
|
|
191
|
+
runUploadStateMachine({ root: fx.root, releaseId: "../../outside", platform: "win", tier: "patch" }),
|
|
192
|
+
/invalid release id/i,
|
|
193
|
+
);
|
|
194
|
+
fx.manifest.routes.patch[0].key = "fixture/installers/windows/current/Fixture.exe&whoami";
|
|
195
|
+
writeFileSync(path.join(fx.sealedDir, "release-manifest.json"), `${JSON.stringify(fx.manifest, null, 2)}\n`);
|
|
196
|
+
await assert.rejects(
|
|
197
|
+
runUploadStateMachine({ root: fx.root, releaseId: fx.manifest.releaseId, platform: "win", tier: "patch" }),
|
|
198
|
+
/unsafe R2 key/i,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("release and test Cargo targets are disjoint", () => {
|
|
203
|
+
const root = "C:/repo/.right-release";
|
|
204
|
+
const build = releaseEnvironment({ root, platform: "win", cacheKey: "deps123", kind: "release" });
|
|
205
|
+
const testEnv = releaseEnvironment({ root, platform: "win", cacheKey: "deps123", kind: "test" });
|
|
206
|
+
assert.notEqual(build.CARGO_TARGET_DIR, testEnv.CARGO_TARGET_DIR);
|
|
207
|
+
assert.match(build.CARGO_TARGET_DIR, /cache[\\/]cargo-target[\\/]win[\\/]deps123/);
|
|
208
|
+
assert.match(testEnv.CARGO_TARGET_DIR, /cache[\\/]test-target[\\/]win[\\/]deps123/);
|
|
209
|
+
assert.match(build.SCCACHE_DIR, /cache[\\/]sccache/);
|
|
210
|
+
assert.equal(build.RUSTC_WRAPPER, "sccache");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("dependency cache survives source commits and invalidates only dependency/toolchain inputs", () => {
|
|
214
|
+
const base = {
|
|
215
|
+
cargoLockSha256: "lock-a",
|
|
216
|
+
rustc: "rustc 1.90.0 host x86_64-pc-windows-msvc",
|
|
217
|
+
target: "x86_64-pc-windows-msvc",
|
|
218
|
+
features: ["bundled-sqlcipher-vendored-openssl"],
|
|
219
|
+
};
|
|
220
|
+
assert.equal(cacheFingerprint({ ...base, commit: "aaa" }), cacheFingerprint({ ...base, commit: "bbb" }));
|
|
221
|
+
assert.notEqual(cacheFingerprint(base), cacheFingerprint({ ...base, cargoLockSha256: "lock-b" }));
|
|
222
|
+
assert.notEqual(cacheFingerprint(base), cacheFingerprint({ ...base, rustc: "rustc 1.91.0" }));
|
|
223
|
+
assert.notEqual(cacheFingerprint(base), cacheFingerprint({ ...base, features: ["bundled-sqlcipher"] }));
|
|
224
|
+
});
|
package/release.mjs
CHANGED
|
@@ -59,7 +59,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
59
59
|
else usage(2, `unknown argument: ${arg}`);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
if (
|
|
62
|
+
if (opts.upload) fail("combined build+upload was removed; run right-release build, then right-release upload --release <id> --tier patch|update");
|
|
63
63
|
if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
|
|
64
64
|
|
|
65
65
|
const configPath = path.resolve(opts.config);
|
|
@@ -103,7 +103,7 @@ if (opts.doctor) {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
|
|
106
|
-
const mode =
|
|
106
|
+
const mode = "package";
|
|
107
107
|
const command = target.package;
|
|
108
108
|
if (!command) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
|
|
109
109
|
if (opts.upload && !target.publish && !target.upload) {
|
|
@@ -111,7 +111,7 @@ if (opts.upload && !target.publish && !target.upload) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
const started = Date.now();
|
|
114
|
-
console.log(`right-release ${VERSION}: ${config.app ?? path.basename(root)} ${opts.platform} ${mode} tier=${opts.tier}`);
|
|
114
|
+
console.log(`right-release ${VERSION}: ${config.app ?? path.basename(root)} ${opts.platform} ${mode} tier=${opts.tier ?? "neutral"}`);
|
|
115
115
|
|
|
116
116
|
if (opts.install) await runInstall(config.packageManager, workdir);
|
|
117
117
|
if (!opts.skipChecks) {
|
package/release.test.mjs
CHANGED
|
@@ -128,10 +128,11 @@ function runRaw(config, ...args) {
|
|
|
128
128
|
});
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
test("
|
|
131
|
+
test("accepts a tier-neutral internal build", () => {
|
|
132
132
|
const result = run(fixture());
|
|
133
|
-
assert.
|
|
134
|
-
assert.match(result.
|
|
133
|
+
assert.equal(result.status, 0, result.stderr);
|
|
134
|
+
assert.match(result.stdout, /tier=neutral/);
|
|
135
|
+
assert.doesNotMatch(result.stdout, /RIGHT_RELEASE_TIER=/);
|
|
135
136
|
});
|
|
136
137
|
|
|
137
138
|
test("accepts patch and exposes it to the signed package command", () => {
|
|
@@ -233,35 +234,31 @@ test("doctor rejects a RightKit override at an ancestor repository root", () =>
|
|
|
233
234
|
assert.match(result.stderr, /forbidden RightKit Cargo override/i);
|
|
234
235
|
});
|
|
235
236
|
|
|
236
|
-
test("
|
|
237
|
+
test("combined build and upload is rejected before the package step", () => {
|
|
237
238
|
const result = run(fixture({ publish: true }), "--tier=update", "--upload");
|
|
238
|
-
assert.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
assert.ok(packageAt >= 0, result.stdout);
|
|
242
|
-
assert.ok(publishAt > packageAt, result.stdout);
|
|
239
|
+
assert.notEqual(result.status, 0);
|
|
240
|
+
assert.match(result.stderr, /combined build\+upload was removed/i);
|
|
241
|
+
assert.doesNotMatch(result.stdout, /node -e process\.exit\(0\)/);
|
|
243
242
|
});
|
|
244
243
|
|
|
245
|
-
test("Windows
|
|
246
|
-
const result = run(fixture({ publish: true })
|
|
244
|
+
test("Windows tier-neutral build signs the installer then minisigns the updater payload", () => {
|
|
245
|
+
const result = run(fixture({ publish: true }));
|
|
247
246
|
assert.equal(result.status, 0, result.stderr);
|
|
248
247
|
const azureAt = result.stdout.indexOf("sign-windows.mjs");
|
|
249
248
|
const updaterAt = result.stdout.indexOf("sign-updater.mjs");
|
|
250
|
-
const publishAt = result.stdout.indexOf("publish-update.mjs");
|
|
251
249
|
assert.ok(azureAt >= 0, result.stdout);
|
|
252
250
|
assert.ok(updaterAt > azureAt, result.stdout);
|
|
253
|
-
assert.
|
|
251
|
+
assert.doesNotMatch(result.stdout, /publish-update\.mjs/);
|
|
254
252
|
});
|
|
255
253
|
|
|
256
|
-
test("
|
|
257
|
-
const result = run(fixture({ publish: true }), "--tier=update"
|
|
254
|
+
test("legacy internal tier does not make the build worker upload", () => {
|
|
255
|
+
const result = run(fixture({ publish: true }), "--tier=update");
|
|
258
256
|
assert.equal(result.status, 0, result.stderr);
|
|
259
257
|
const azureAt = result.stdout.indexOf("sign-windows.mjs");
|
|
260
258
|
const updaterAt = result.stdout.indexOf("sign-updater.mjs");
|
|
261
|
-
const publishAt = result.stdout.indexOf("publish-update.mjs");
|
|
262
259
|
assert.ok(azureAt >= 0, result.stdout);
|
|
263
260
|
assert.ok(updaterAt > azureAt, result.stdout);
|
|
264
|
-
assert.
|
|
261
|
+
assert.doesNotMatch(result.stdout, /publish-update\.mjs/);
|
|
265
262
|
});
|
|
266
263
|
|
|
267
264
|
test("release sweeps stale rust artifacts when a target dir exists", () => {
|
|
@@ -317,7 +317,7 @@ test("Cargo version contract rejects staged versions that mismatch canonical man
|
|
|
317
317
|
test("RightKit exposes one current version manifest", () => {
|
|
318
318
|
assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
|
|
319
319
|
assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
|
|
320
|
-
assert.equal(versions.npm["@rightkit/release"], "0.2.
|
|
320
|
+
assert.equal(versions.npm["@rightkit/release"], "0.2.32");
|
|
321
321
|
assert.equal(versions.npm["@rightkit/legal"], "0.2.0");
|
|
322
322
|
assert.equal(versions.npm["@rightkit/license"], "0.1.5");
|
|
323
323
|
assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
|
|
@@ -327,10 +327,9 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
327
327
|
"@rightkit/legal": "0.3.0",
|
|
328
328
|
"@rightkit/legal-ui": "0.1.0",
|
|
329
329
|
"@rightkit/license": "0.1.6",
|
|
330
|
-
"@rightkit/release": "0.2.31",
|
|
331
330
|
});
|
|
332
331
|
assert.deepEqual(versions.legacyNpm, {
|
|
333
|
-
"@rightkit/release": ["0.2.22", "0.2.29"],
|
|
332
|
+
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31"],
|
|
334
333
|
});
|
|
335
334
|
const licensePackage = JSON.parse(readFileSync(
|
|
336
335
|
path.join(workspace, "tools/rightkit/packages/license/package.json"),
|
package/rightkit-versions.json
CHANGED
|
@@ -7,18 +7,17 @@
|
|
|
7
7
|
"@rightkit/logs": "0.1.3",
|
|
8
8
|
"@rightkit/platform-ui": "0.1.0",
|
|
9
9
|
"@rightkit/qa": "0.1.0",
|
|
10
|
-
"@rightkit/release": "0.2.
|
|
10
|
+
"@rightkit/release": "0.2.32",
|
|
11
11
|
"@rightkit/tauri": "0.1.0",
|
|
12
12
|
"@rightkit/updates": "0.2.3"
|
|
13
13
|
},
|
|
14
14
|
"stagedNpm": {
|
|
15
15
|
"@rightkit/legal": "0.3.0",
|
|
16
16
|
"@rightkit/legal-ui": "0.1.0",
|
|
17
|
-
"@rightkit/license": "0.1.6"
|
|
18
|
-
"@rightkit/release": "0.2.31"
|
|
17
|
+
"@rightkit/license": "0.1.6"
|
|
19
18
|
},
|
|
20
19
|
"legacyNpm": {
|
|
21
|
-
"@rightkit/release": ["0.2.22", "0.2.29"]
|
|
20
|
+
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31"]
|
|
22
21
|
},
|
|
23
22
|
"cargo": {
|
|
24
23
|
"rightkit-license": "0.1.2",
|
|
@@ -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
|
+
}
|