@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.
@@ -0,0 +1,388 @@
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, resolveReleaseLayout, 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 = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) }).vaultRoot;
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 appRoot = path.dirname(worktreeConfigPath);
75
+ const layout = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) });
76
+ const config = (await import(`${pathToFileURL(worktreeConfigPath).href}?commit=${commit}`)).default;
77
+ if (!config?.app || !config?.version) fail("release config must expose app and version");
78
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(config.app) || !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(config.version)) {
79
+ fail("app and version must be safe release-id components");
80
+ }
81
+ const target = config.targets?.[platform];
82
+ if (!target?.package) fail(`${config.app} has no ${platform} package command`);
83
+ const requiredInputs = inputPaths(appRoot, worktreeConfigPath, target);
84
+ const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
85
+ const inputHashes = hashInputs(requiredInputs);
86
+ const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
87
+ const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
88
+ const cacheKey = cacheFingerprint({
89
+ cargoLockSha256: cargoLock ? inputHashes[path.relative(worktree, cargoLock)] : "none",
90
+ rustc: toolVersions.rustc,
91
+ target: toolVersions.rustHost,
92
+ features: cargoToml ? sqlCipherFeatures(readFileSync(cargoToml, "utf8")) : [],
93
+ });
94
+ const env = {
95
+ ...process.env,
96
+ ...releaseEnvironment({ root: vaultRoot, platform, cacheKey, kind: "release" }),
97
+ RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
98
+ RIGHT_RELEASE_APP_ROOT: layout.appRoot,
99
+ };
100
+ if (!commandExists("sccache")) delete env.RUSTC_WRAPPER;
101
+ const releaseId = `${config.app}-${config.version}-${shortCommit}`;
102
+ const platformDir = platform === "win" ? "windows" : "mac";
103
+ const buildRoot = path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
104
+ const stateRoot = path.join(vaultRoot, "state", releaseId, platformDir);
105
+ const lockPayload = {
106
+ schema: 1,
107
+ releaseId,
108
+ app: config.app,
109
+ version: config.version,
110
+ commit,
111
+ platform,
112
+ cacheKey,
113
+ inputs: inputHashes,
114
+ tools: toolVersions,
115
+ createdAt: new Date().toISOString(),
116
+ };
117
+
118
+ const result = await runBuildStateMachine({
119
+ root: repoRoot,
120
+ app: config.app,
121
+ version: config.version,
122
+ commit,
123
+ platform,
124
+ requiredInputs,
125
+ ops: {
126
+ preflight: async () => {
127
+ assertDiskSpace(repoRoot, target.preflight?.minFreeGb ?? 20);
128
+ assertExecutables(["git", config.packageManager ?? "pnpm", "cargo", "rustc", ...(target.preflight?.executables ?? [])]);
129
+ for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
130
+ for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(appRoot, command.cwd ?? "."), env);
131
+ mkdirSync(buildRoot, { recursive: true });
132
+ mkdirSync(stateRoot, { recursive: true });
133
+ writeJson(path.join(buildRoot, "release-inputs.lock.json"), lockPayload);
134
+ writeJson(path.join(stateRoot, "release-inputs.lock.json"), lockPayload);
135
+ checkpoint(stateRoot, "preflight_complete");
136
+ },
137
+ prepare: async () => {
138
+ const cacheTarget = env.CARGO_TARGET_DIR;
139
+ const targetLink = path.join(appRoot, "src-tauri", "target");
140
+ mkdirSync(cacheTarget, { recursive: true });
141
+ if (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
142
+ else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
143
+ fail(`worktree target exists and is not the release cache link: ${targetLink}`);
144
+ }
145
+ await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget);
146
+ },
147
+ build: async () => {
148
+ await runProgress(process.execPath, [WORKER, "--config", worktreeConfigPath, "--platform", platform, "--no-upload"], appRoot, env, env.CARGO_TARGET_DIR);
149
+ checkpoint(stateRoot, "build_complete");
150
+ checkpoint(stateRoot, "signed");
151
+ checkpoint(stateRoot, "hardened");
152
+ },
153
+ seal: async ({ sealedDir }) => {
154
+ sealRelease({ configRoot: path.dirname(worktreeConfigPath), sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey });
155
+ checkpoint(stateRoot, "sealed");
156
+ },
157
+ },
158
+ });
159
+ console.log(`right-release build: ${result.resumed ? "resumed" : "sealed"} ${result.releaseId}`);
160
+ console.log(`sealed: ${result.sealedDir}`);
161
+ } finally {
162
+ lock.release();
163
+ }
164
+
165
+ function inputPaths(appRoot, configPath, target) {
166
+ const candidates = [
167
+ configPath,
168
+ path.join(appRoot, "package.json"),
169
+ path.join(appRoot, "pnpm-lock.yaml"),
170
+ path.join(appRoot, "src-tauri", "Cargo.toml"),
171
+ path.join(appRoot, "src-tauri", "Cargo.lock"),
172
+ ...(target.preflight?.files ?? []).map((file) => path.resolve(appRoot, expandEnv(file))),
173
+ ];
174
+ const required = [...new Set(candidates)];
175
+ for (const file of required) if (!existsSync(file)) fail(`missing required release input: ${file}`);
176
+ return required;
177
+ }
178
+
179
+ function hashInputs(files) {
180
+ return Object.fromEntries(files.map((file) => [path.relative(path.dirname(path.dirname(file)), file), hashFile(file)]));
181
+ }
182
+
183
+ function hashFile(file) {
184
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
185
+ }
186
+
187
+ function sqlCipherFeatures(text) {
188
+ return [...text.matchAll(/features\s*=\s*\[([^\]]+)\]/g)]
189
+ .flatMap((match) => match[1].match(/"([^"]+)"/g) ?? [])
190
+ .map((value) => value.slice(1, -1))
191
+ .filter((value) => /sqlcipher|openssl/i.test(value));
192
+ }
193
+
194
+ function sealRelease({ configRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey }) {
195
+ const sources = new Map();
196
+ const addSource = (source, role) => {
197
+ const current = sources.get(source) ?? new Set();
198
+ current.add(role);
199
+ sources.set(source, current);
200
+ };
201
+ for (const artifact of target.installer?.artifacts ?? []) addSource(path.resolve(configRoot, artifact.file), "installer");
202
+ for (const artifact of target.updater?.artifacts ?? []) {
203
+ addSource(path.resolve(configRoot, artifact.file), "updater");
204
+ addSource(path.resolve(configRoot, artifact.signature), "updater-signature");
205
+ }
206
+ for (const file of sources.keys()) if (!existsSync(file)) fail(`cannot seal missing artifact: ${file}`);
207
+ const temp = `${sealedDir}.tmp-${process.pid}`;
208
+ if (existsSync(temp)) rmSync(temp, { recursive: true, force: true });
209
+ mkdirSync(temp, { recursive: true });
210
+ const files = [];
211
+ const sourceNames = new Map();
212
+ for (const [source, roles] of sources) {
213
+ let name = path.basename(source);
214
+ if ([...sourceNames.values()].includes(name) && sourceNames.get(source) !== name) name = `${[...roles].join("-")}-${name}`;
215
+ sourceNames.set(source, name);
216
+ copyFileSync(source, path.join(temp, name));
217
+ files.push({ role: [...roles].sort().join("+"), name, sha256: hashFile(source), sizeBytes: statSync(source).size });
218
+ }
219
+ const routes = { patch: [], update: [] };
220
+ for (const artifact of target.installer?.artifacts ?? []) {
221
+ const source = path.resolve(configRoot, artifact.file);
222
+ routes.patch.push({ role: "installer", name: sourceNames.get(source), bucket: "public", key: artifact.key });
223
+ }
224
+ for (const artifact of target.updater?.artifacts ?? []) {
225
+ const source = path.resolve(configRoot, artifact.file);
226
+ const patchKey = artifact.patchKey ?? artifact.key.replace("/updates/", "/installers/");
227
+ routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(path.resolve(configRoot, artifact.signature)), bucket: "public", key: patchKey, platform: artifact.platform });
228
+ 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 });
229
+ }
230
+ writeJson(path.join(temp, "hardening.json"), { schema: 1, passed: true, checkedAt: new Date().toISOString(), files: files.map((file) => file.sha256) });
231
+ const manifest = {
232
+ schema: 1,
233
+ releaseId,
234
+ app: config.app,
235
+ version: config.version,
236
+ commit,
237
+ platform,
238
+ cacheKey,
239
+ files,
240
+ routes,
241
+ inputs: inputHashes,
242
+ tools: toolVersions,
243
+ checkpoints: ["preflight_complete", "build_complete", "signed", "hardened", "sealed"],
244
+ sealedAt: new Date().toISOString(),
245
+ };
246
+ writeJson(path.join(temp, "release-manifest.json"), manifest);
247
+ if (existsSync(sealedDir)) fail(`sealed directory already exists: ${sealedDir}`);
248
+ mkdirSync(path.dirname(sealedDir), { recursive: true });
249
+ renameSync(temp, sealedDir);
250
+ for (const item of files) {
251
+ const file = path.join(sealedDir, item.name);
252
+ try { chmodSync(file, 0o444); } catch { /* best effort; hashes are authoritative */ }
253
+ }
254
+ }
255
+
256
+ function collectToolVersions(packageManager) {
257
+ const rustcVerbose = commandOutput("rustc", ["-vV"]);
258
+ return {
259
+ node: process.version,
260
+ packageManager: `${packageManager} ${commandOutput(packageManager, ["--version"])}`,
261
+ cargo: commandOutput("cargo", ["--version"]),
262
+ rustc: rustcVerbose,
263
+ rustHost: rustcVerbose.match(/^host:\s*(.+)$/m)?.[1] ?? "unknown",
264
+ sccache: commandExists("sccache") ? commandOutput("sccache", ["--version"]) : null,
265
+ };
266
+ }
267
+
268
+ async function runProgress(cmd, runArgs, cwd, env, watchDir) {
269
+ const inactivityMs = Number(process.env.RIGHT_RELEASE_STALL_MS ?? 10 * 60 * 1000);
270
+ const absoluteMs = Number(process.env.RIGHT_RELEASE_ABSOLUTE_MS ?? 90 * 60 * 1000);
271
+ let lastProgress = Date.now();
272
+ let lastMtime = newestMtime(watchDir);
273
+ const started = Date.now();
274
+ await new Promise((resolve, reject) => {
275
+ child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32" });
276
+ for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
277
+ stream.on("data", (chunk) => { lastProgress = Date.now(); output.write(chunk); });
278
+ }
279
+ const monitor = setInterval(() => {
280
+ const mtime = newestMtime(watchDir);
281
+ if (mtime > lastMtime) { lastMtime = mtime; lastProgress = Date.now(); }
282
+ if (Date.now() - started > absoluteMs) stop(`absolute limit exceeded (${Math.round(absoluteMs / 60000)}m)`);
283
+ else if (Date.now() - lastProgress > inactivityMs) stop(`no output or file progress for ${Math.round(inactivityMs / 60000)}m`);
284
+ }, Math.min(30_000, Math.max(250, Math.floor(inactivityMs / 4))));
285
+ const stop = (reason) => { clearInterval(monitor); killTree(child.pid); reject(new Error(`release step stalled: ${reason}`)); };
286
+ child.once("error", (error) => { clearInterval(monitor); reject(error); });
287
+ child.once("exit", (code) => { clearInterval(monitor); child = null; code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`)); });
288
+ });
289
+ }
290
+
291
+ function newestMtime(dir) {
292
+ if (!existsSync(dir)) return 0;
293
+ try { return statSync(dir).mtimeMs; } catch { return 0; }
294
+ }
295
+
296
+ function checkpoint(stateRoot, name) {
297
+ const file = path.join(stateRoot, "checkpoints.json");
298
+ const state = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { schema: 1, completed: [] };
299
+ if (!state.completed.includes(name)) state.completed.push(name);
300
+ state.updatedAt = new Date().toISOString();
301
+ writeJson(file, state);
302
+ }
303
+
304
+ function acquireLock(file) {
305
+ if (existsSync(file)) {
306
+ try {
307
+ const existing = JSON.parse(readFileSync(file, "utf8"));
308
+ if (!processAlive(existing.pid)) unlinkSync(file);
309
+ } catch {
310
+ unlinkSync(file);
311
+ }
312
+ }
313
+ try {
314
+ const fd = openSync(file, "wx");
315
+ writeFileSync(fd, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), argv: process.argv })}\n`);
316
+ closeSync(fd);
317
+ return { release: () => { try { unlinkSync(file); } catch { /* already released */ } } };
318
+ } catch (error) {
319
+ if (error.code === "EEXIST") fail(`another ${platform} release is active: ${file}`);
320
+ throw error;
321
+ }
322
+ }
323
+
324
+ function processAlive(pid) {
325
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
326
+ try { process.kill(Number(pid), 0); return true; } catch { return false; }
327
+ }
328
+
329
+ function assertDiskSpace(dir, minimumGb) {
330
+ const stats = statfsSync(dir);
331
+ const freeGb = Number(stats.bavail * stats.bsize) / 1024 ** 3;
332
+ if (freeGb < minimumGb) fail(`insufficient disk space: ${freeGb.toFixed(1)} GiB free, ${minimumGb} GiB required`);
333
+ }
334
+
335
+ function assertExecutables(names) {
336
+ for (const name of names) if (!commandExists(name)) fail(`required executable not found: ${name}`);
337
+ }
338
+
339
+ function commandExists(name) {
340
+ const probe = process.platform === "win32" ? spawnSync("where.exe", [name], { stdio: "ignore", windowsHide: true }) : spawnSync("sh", ["-lc", `command -v '${name.replaceAll("'", "")}'`], { stdio: "ignore" });
341
+ return probe.status === 0;
342
+ }
343
+
344
+ function commandOutput(cmd, runArgs) {
345
+ const result = spawnSync(cmd, runArgs, { encoding: "utf8", windowsHide: true });
346
+ if (result.status !== 0) fail(`required command failed: ${cmd} ${runArgs.join(" ")}`);
347
+ return result.stdout.trim();
348
+ }
349
+
350
+ function git(cwd, runArgs) {
351
+ return commandOutputAt("git", runArgs, cwd);
352
+ }
353
+
354
+ function commandOutputAt(cmd, runArgs, cwd) {
355
+ const result = spawnSync(cmd, runArgs, { cwd, encoding: "utf8", windowsHide: true });
356
+ if (result.status !== 0) fail(`${cmd} ${runArgs.join(" ")} failed: ${(result.stderr ?? "").trim()}`);
357
+ return result.stdout.trim();
358
+ }
359
+
360
+ function runChecked(cmd, runArgs, cwd, env = process.env) {
361
+ const result = spawnSync(cmd, runArgs, { cwd, env, stdio: "inherit", windowsHide: true, shell: false });
362
+ if (result.status !== 0) fail(`${cmd} exited ${result.status}`);
363
+ }
364
+
365
+ function killTree(pid) {
366
+ if (!pid) return;
367
+ if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
368
+ else { try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } } }
369
+ }
370
+
371
+ function expandEnv(value) {
372
+ return String(value).replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "").replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? "");
373
+ }
374
+
375
+ function writeJson(file, value) {
376
+ mkdirSync(path.dirname(file), { recursive: true });
377
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
378
+ }
379
+
380
+ function usage(code) {
381
+ console.log("usage: right-release build --platform win|mac [--config right-release.config.mjs] [--dry-run]");
382
+ process.exit(code);
383
+ }
384
+
385
+ function fail(message) {
386
+ console.error(`right-release build: ${message}`);
387
+ process.exit(1);
388
+ }
@@ -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.includes("--upload") || rest.includes("--publish") ? rest : [...rest, "--upload"]);
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
- release [--platform mac|win] --tier patch|update Build/package through the signed release lane
123
- publish [--platform mac|win] --tier patch|update Release plus R2 upload + RightApps registration
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 release <flags>.
139
- Publishing requires an explicit tier. Unsigned/local smoke builds stay app-local unless declared in right-release.config.mjs.`);
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
  }
@@ -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 single assent surface. An installer or DMG can only
139
- // present text; it cannot record who agreed or the individual/enterprise basis.
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}`);
@@ -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.31",
3
+ "version": "0.2.33",
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
+ });