@rightkit/release 0.2.61 → 0.2.63

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