@rightkit/release 0.2.8 → 0.2.10

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.
@@ -39,6 +39,8 @@ if (first === "--version" || first === "-v") {
39
39
  run("release.mjs", rest.includes("--upload") || rest.includes("--publish") ? rest : [...rest, "--upload"]);
40
40
  } else if (first === "lsclean") {
41
41
  runBinary("bash", [path.join(packageRoot, "lsclean.sh"), ...args.slice(1)]);
42
+ } else if (first === "generate-dmg-background") {
43
+ runBinary("python3", [path.join(packageRoot, "generate-dmg-background.py"), ...args.slice(1)]);
42
44
  } else if (commands.has(first)) {
43
45
  run(commands.get(first), args.slice(1));
44
46
  } else if (first.startsWith("-")) {
@@ -102,6 +104,7 @@ Commands:
102
104
  deps --check|--audit|--update Shared dependency lane
103
105
  hardening <artifact...> Run the Right Suite hardening scan
104
106
  lsclean <AppName.app> Clear macOS LaunchServices duplicates
107
+ generate-dmg-background <options> Generate the branded multi-resolution DMG background
105
108
 
106
109
  Direct flags are treated as: right-release release <flags>.
107
110
  Publishing requires an explicit tier. Unsigned/local smoke builds stay app-local unless declared in right-release.config.mjs.`);
package/deps.mjs CHANGED
File without changes
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import subprocess
6
+ from pathlib import Path
7
+ from PIL import Image, ImageDraw, ImageFilter
8
+
9
+
10
+ def rgb(value: str) -> tuple[int, int, int]:
11
+ value = value.removeprefix("#")
12
+ if len(value) != 6:
13
+ raise argparse.ArgumentTypeError("colors must be six-digit hex values")
14
+ return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4))
15
+
16
+
17
+ def make(width: int, height: int, scale: int, background: tuple[int, int, int], header: tuple[int, int, int], accent: tuple[int, int, int], icon_path: Path) -> Image.Image:
18
+ image = Image.new("RGBA", (width, height), (*background, 255))
19
+ draw = ImageDraw.Draw(image)
20
+ band = 86 * scale
21
+ draw.rectangle((0, 0, width, band), fill=(*header, 255))
22
+ draw.rectangle((0, band - 2 * scale, width, band), fill=(*accent, 255))
23
+ arrow = Image.new("RGBA", (width, height), (0, 0, 0, 0))
24
+ ad = ImageDraw.Draw(arrow)
25
+ stroke = 4 * scale
26
+ ad.line([(315 * scale, 231 * scale), (463 * scale, 231 * scale)], fill=(*accent, 255), width=stroke)
27
+ ad.line([(438 * scale, 205 * scale), (466 * scale, 231 * scale), (438 * scale, 257 * scale)], fill=(*accent, 255), width=stroke, joint="curve")
28
+ image.alpha_composite(arrow.filter(ImageFilter.GaussianBlur(scale)))
29
+ image.alpha_composite(arrow)
30
+ if icon_path.exists():
31
+ icon = Image.open(icon_path).convert("RGBA").resize((48 * scale, 48 * scale), Image.Resampling.LANCZOS)
32
+ image.alpha_composite(icon, (34 * scale, 20 * scale))
33
+ return image.convert("RGB")
34
+
35
+
36
+ def main() -> None:
37
+ p = argparse.ArgumentParser()
38
+ p.add_argument("--output-dir", required=True, type=Path)
39
+ p.add_argument("--icon", required=True, type=Path)
40
+ p.add_argument("--background", required=True, type=rgb)
41
+ p.add_argument("--header", required=True, type=rgb)
42
+ p.add_argument("--accent", required=True, type=rgb)
43
+ a = p.parse_args()
44
+ a.output_dir.mkdir(parents=True, exist_ok=True)
45
+ low, high, tiff = (a.output_dir / name for name in ("background.png", "background@2x.png", "background.tiff"))
46
+ make(660, 400, 1, a.background, a.header, a.accent, a.icon).save(low)
47
+ make(1320, 800, 2, a.background, a.header, a.accent, a.icon).save(high)
48
+ subprocess.run(["tiffutil", "-cathidpicheck", str(low), str(high), "-out", str(tiff)], check=True)
49
+ print(tiff)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
package/lsclean.sh CHANGED
File without changes
@@ -0,0 +1,21 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export function notarytoolAuthArgs({ profile = "apple-dev-notary" } = {}) {
5
+ const keyPath = process.env.APPLE_API_KEY_PATH?.trim();
6
+ const keyId = process.env.APPLE_API_KEY?.trim();
7
+ const issuer = process.env.APPLE_API_ISSUER?.trim();
8
+ const apiValues = [keyPath, keyId, issuer];
9
+
10
+ if (apiValues.every(Boolean)) {
11
+ return ["--key", expandHome(keyPath), "--key-id", keyId, "--issuer", issuer];
12
+ }
13
+ if (apiValues.some(Boolean)) {
14
+ throw new Error("Notarization API auth requires APPLE_API_KEY_PATH, APPLE_API_KEY, and APPLE_API_ISSUER together");
15
+ }
16
+ return ["--keychain-profile", profile];
17
+ }
18
+
19
+ function expandHome(value) {
20
+ return value === "~" ? os.homedir() : value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
21
+ }
@@ -0,0 +1,30 @@
1
+ import assert from "node:assert/strict";
2
+ import os from "node:os";
3
+ import test from "node:test";
4
+ import { notarytoolAuthArgs } from "./notary-auth.mjs";
5
+
6
+ test("prefers a complete App Store Connect API credential set", () => {
7
+ const before = { ...process.env };
8
+ try {
9
+ process.env.APPLE_API_KEY_PATH = "~/AuthKey.p8";
10
+ process.env.APPLE_API_KEY = "KEY123";
11
+ process.env.APPLE_API_ISSUER = "ISSUER123";
12
+ assert.deepEqual(notarytoolAuthArgs(), ["--key", `${os.homedir()}/AuthKey.p8`, "--key-id", "KEY123", "--issuer", "ISSUER123"]);
13
+ } finally {
14
+ process.env = before;
15
+ }
16
+ });
17
+
18
+ test("falls back to the device keychain profile and rejects partial API auth", () => {
19
+ const before = { ...process.env };
20
+ try {
21
+ delete process.env.APPLE_API_KEY_PATH;
22
+ delete process.env.APPLE_API_KEY;
23
+ delete process.env.APPLE_API_ISSUER;
24
+ assert.deepEqual(notarytoolAuthArgs(), ["--keychain-profile", "apple-dev-notary"]);
25
+ process.env.APPLE_API_KEY = "KEY123";
26
+ assert.throws(() => notarytoolAuthArgs(), /requires APPLE_API_KEY_PATH.*APPLE_API_ISSUER/);
27
+ } finally {
28
+ process.env = before;
29
+ }
30
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
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": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "cli",
11
11
  "*.mjs",
12
- "*.sh"
12
+ "*.sh",
13
+ "*.py"
13
14
  ],
14
15
  "sideEffects": false,
15
16
  "scripts": {
@@ -20,5 +21,10 @@
20
21
  "registry": "https://registry.npmjs.org/",
21
22
  "access": "public"
22
23
  },
23
- "packageManager": "pnpm@11.9.0"
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/adrdsouza/claude.git",
27
+ "directory": "tools/rightkit/packages/release"
28
+ },
29
+ "packageManager": "pnpm@11.12.0"
24
30
  }
package/prune-r2.mjs ADDED
@@ -0,0 +1,56 @@
1
+ const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || "03ae77ccd7a07bcbb2dcfde47fa7ba3a";
2
+ const BUCKETS = { public: "rightapps-downloads", private: "rightapps-updates" };
3
+
4
+ export async function pruneReleaseObjects({ app, platform, keep, dryRun = false }) {
5
+ if (!new Set(["mac", "windows"]).has(platform)) throw new Error(`invalid R2 prune platform: ${platform}`);
6
+ for (const [alias, bucket] of Object.entries(BUCKETS)) {
7
+ const retained = new Set(keep[alias] ?? []);
8
+ if (dryRun) {
9
+ console.log(`[dry-run] PRUNE ${bucket}/${app}/${platform} keep=${[...retained].sort().join(",") || "<none>"}`);
10
+ continue;
11
+ }
12
+ const token = process.env.CLOUDFLARE_API_TOKEN;
13
+ if (!token) throw new Error("CLOUDFLARE_API_TOKEN is required to prune stale R2 release objects");
14
+ for (const key of await listObjects(bucket, app, token)) {
15
+ if (!isReleaseObject(app, platform, key) || retained.has(key)) continue;
16
+ console.log(`DELETE ${bucket}/${key}`);
17
+ await deleteObject(bucket, key, token);
18
+ }
19
+ }
20
+ }
21
+
22
+ export function isReleaseObject(app, platform, key) {
23
+ if (!key.startsWith(`${app}/`)) return false;
24
+ const relative = key.slice(app.length + 1);
25
+ const segments = relative.split("/");
26
+ if (!segments.includes(platform)) return false;
27
+ if (/^(?:installers|updates|updaters|releases)\//.test(relative)) return true;
28
+ return /^v?\d+\.\d+\.\d+(?:[-+][^/]+)?\//.test(relative);
29
+ }
30
+
31
+ async function listObjects(bucket, app, token) {
32
+ const objects = [];
33
+ let cursor = "";
34
+ do {
35
+ const url = new URL(`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/r2/buckets/${bucket}/objects`);
36
+ url.searchParams.set("prefix", `${app}/`);
37
+ if (cursor) url.searchParams.set("cursor", cursor);
38
+ const response = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
39
+ const body = await response.json().catch(() => ({}));
40
+ if (!response.ok || body.success === false) throw new Error(`R2 list failed for ${bucket}: ${response.status}`);
41
+ const page = Array.isArray(body.result) ? body.result : body.result?.objects ?? [];
42
+ objects.push(...page.map((entry) => entry.key).filter(Boolean));
43
+ cursor = body.result_info?.cursor || body.result?.cursor || "";
44
+ } while (cursor);
45
+ return objects;
46
+ }
47
+
48
+ async function deleteObject(bucket, key, token) {
49
+ const encoded = key.split("/").map(encodeURIComponent).join("/");
50
+ const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/r2/buckets/${bucket}/objects/${encoded}`, {
51
+ method: "DELETE",
52
+ headers: { authorization: `Bearer ${token}` },
53
+ });
54
+ const body = await response.json().catch(() => ({}));
55
+ if (!response.ok || body.success === false) throw new Error(`R2 delete failed for ${bucket}/${key}: ${response.status}`);
56
+ }
@@ -0,0 +1,12 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { isReleaseObject } from "./prune-r2.mjs";
4
+
5
+ test("pruning only targets release objects for the active OS", () => {
6
+ assert.equal(isReleaseObject("viewright", "mac", "viewright/installers/mac/ViewRight.dmg"), true);
7
+ assert.equal(isReleaseObject("viewright", "mac", "viewright/updates/mac/current/ViewRight.app.tar.gz"), true);
8
+ assert.equal(isReleaseObject("viewright", "mac", "viewright/installers/windows/ViewRight-Setup.exe"), false);
9
+ assert.equal(isReleaseObject("viewright", "windows", "viewright/installers/mac/ViewRight.dmg"), false);
10
+ assert.equal(isReleaseObject("viewright", "mac", "viewright/models/mac/model.bin"), false);
11
+ assert.equal(isReleaseObject("viewright", "mac", "other/installers/mac/Other.dmg"), false);
12
+ });
@@ -4,6 +4,7 @@ import { access, readFile, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { spawn } from "node:child_process";
7
+ import { pruneReleaseObjects } from "./prune-r2.mjs";
7
8
 
8
9
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
9
10
  const UPLOAD = path.join(TOOL_ROOT, "upload-large.mjs");
@@ -45,6 +46,7 @@ if (tier === "patch") {
45
46
  const platforms = {};
46
47
  const registrations = new Map();
47
48
  const uploadedKeys = new Set();
49
+ const uploadedSignatures = new Set();
48
50
  for (const artifact of artifacts) {
49
51
  assertCurrentKey(artifact.key, "updater");
50
52
  const installerKey = patchInstallerKey(artifact);
@@ -60,6 +62,11 @@ if (tier === "patch") {
60
62
  if (!dryRun) await run(process.execPath, [UPLOAD, file, installerKey, "public"], root);
61
63
  uploadedKeys.add(installerKey);
62
64
  }
65
+ if (!uploadedSignatures.has(`${installerKey}.sig`)) {
66
+ console.log(`${dryRun ? "[dry-run] " : ""}UPLOAD public/${installerKey}.sig`);
67
+ if (!dryRun) await run(process.execPath, [UPLOAD, signatureFile, `${installerKey}.sig`, "public"], root);
68
+ uploadedSignatures.add(`${installerKey}.sig`);
69
+ }
63
70
  const signature = dryRun ? `<signature:${artifact.signature}>` : (await readFile(signatureFile, "utf8")).trim();
64
71
  const artifactKey = artifact.artifactKey || installerKey.replaceAll("/", "__");
65
72
  const metadata = dryRun ? { sha256: null, sizeBytes: null } : await fileMetadata(file);
@@ -79,6 +86,15 @@ if (tier === "patch") {
79
86
  }
80
87
  if (!artifacts.length) {
81
88
  console.log(`${dryRun ? "[dry-run] " : ""}NO patch updater artifacts; skipping patch manifest registration`);
89
+ await pruneReleaseObjects({
90
+ app: config.app,
91
+ platform: platform === "mac" ? "mac" : "windows",
92
+ dryRun,
93
+ keep: {
94
+ public: installers.map((artifact) => artifact.key),
95
+ private: target.updater.artifacts.flatMap((artifact) => [artifact.key, `${artifact.key}.sig`]),
96
+ },
97
+ });
82
98
  process.exit(0);
83
99
  }
84
100
  const version = config.version || target.updater.version;
@@ -87,7 +103,7 @@ if (tier === "patch") {
87
103
  appKey: config.app,
88
104
  version,
89
105
  channel: config.channel || "stable",
90
- platform: null,
106
+ platform: platform === "mac" ? "darwin" : "windows",
91
107
  manifest: {
92
108
  version,
93
109
  tier,
@@ -100,6 +116,15 @@ if (tier === "patch") {
100
116
  console.log(`${dryRun ? "[dry-run] " : ""}POST ${API_BASE}/v1/admin/apps/patches`);
101
117
  console.log(JSON.stringify(body));
102
118
  if (!dryRun) await register("/v1/admin/apps/patches", body);
119
+ await pruneReleaseObjects({
120
+ app: config.app,
121
+ platform: platform === "mac" ? "mac" : "windows",
122
+ dryRun,
123
+ keep: {
124
+ public: [...installers.map((artifact) => artifact.key), ...registrations.keys(), ...[...registrations.keys()].map((key) => `${key}.sig`)],
125
+ private: [],
126
+ },
127
+ });
103
128
  process.exit(0);
104
129
  }
105
130
 
@@ -108,6 +133,7 @@ if (!artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no u
108
133
  const platforms = {};
109
134
  const registrations = new Map();
110
135
  const uploadedKeys = new Set();
136
+ const uploadedSignatures = new Set();
111
137
  for (const artifact of artifacts) {
112
138
  assertCurrentKey(artifact.key, "updater");
113
139
  const file = path.resolve(root, artifact.file);
@@ -121,6 +147,11 @@ for (const artifact of artifacts) {
121
147
  if (!dryRun) await run(process.execPath, [UPLOAD, file, artifact.key, "private"], root);
122
148
  uploadedKeys.add(artifact.key);
123
149
  }
150
+ if (!uploadedSignatures.has(`${artifact.key}.sig`)) {
151
+ console.log(`${dryRun ? "[dry-run] " : ""}UPLOAD private/${artifact.key}.sig`);
152
+ if (!dryRun) await run(process.execPath, [UPLOAD, signatureFile, `${artifact.key}.sig`, "private"], root);
153
+ uploadedSignatures.add(`${artifact.key}.sig`);
154
+ }
124
155
 
125
156
  const signature = dryRun ? `<signature:${artifact.signature}>` : (await readFile(signatureFile, "utf8")).trim();
126
157
  const artifactKey = artifact.artifactKey || artifact.key.replaceAll("/", "__");
@@ -138,7 +169,7 @@ const body = {
138
169
  appKey: config.app,
139
170
  version,
140
171
  channel: config.channel || "stable",
141
- platform: null,
172
+ platform: platform === "mac" ? "darwin" : "windows",
142
173
  manifest: {
143
174
  version,
144
175
  tier,
@@ -153,6 +184,15 @@ console.log(`${dryRun ? "[dry-run] " : ""}POST ${API_BASE}${route}`);
153
184
  console.log(JSON.stringify(body));
154
185
 
155
186
  if (!dryRun) await register(route, body);
187
+ await pruneReleaseObjects({
188
+ app: config.app,
189
+ platform: platform === "mac" ? "mac" : "windows",
190
+ dryRun,
191
+ keep: {
192
+ public: installers.map((artifact) => artifact.key),
193
+ private: [...registrations.keys(), ...[...registrations.keys()].map((key) => `${key}.sig`)],
194
+ },
195
+ });
156
196
 
157
197
  async function fileMetadata(file) {
158
198
  const bytes = await readFile(file);
@@ -91,6 +91,10 @@ test("patch registers a free updater manifest backed by public installer-path ob
91
91
  assert.match(result.stdout, /pub-6c73208d46c245a9b4881d5e02f6b618\.r2\.dev\/fixture\/installers\/windows\/current\/Fixture\.exe/);
92
92
  assert.match(result.stdout, /POST .*\/v1\/admin\/apps\/patches/);
93
93
  assert.match(result.stdout, /"tier":"patch"/);
94
+ assert.match(result.stdout, /"platform":"windows"/);
95
+ assert.equal((result.stdout.match(/UPLOAD public\/fixture\/installers\/windows\/current\/Fixture\.exe\.sig/g) ?? []).length, 1);
96
+ assert.match(result.stdout, /PRUNE rightapps-downloads\/fixture\/windows/);
97
+ assert.match(result.stdout, /PRUNE rightapps-updates\/fixture\/windows keep=<none>/);
94
98
  });
95
99
 
96
100
  test("routes feature updates to the Pro-gated release endpoint", () => {
@@ -98,8 +102,12 @@ test("routes feature updates to the Pro-gated release endpoint", () => {
98
102
  assert.equal(result.status, 0, result.stderr);
99
103
  assert.match(result.stdout, /POST .*\/v1\/admin\/apps\/releases/);
100
104
  assert.match(result.stdout, /"tier":"update"/);
105
+ assert.match(result.stdout, /"platform":"windows"/);
101
106
  assert.match(result.stdout, /UPLOAD private\/fixture\/updates\/windows\/current\/Fixture\.exe/);
102
- assert.doesNotMatch(result.stdout, /Fixture-Setup\.exe/);
107
+ assert.equal((result.stdout.match(/UPLOAD private\/fixture\/updates\/windows\/current\/Fixture\.exe\.sig/g) ?? []).length, 1);
108
+ assert.match(result.stdout, /PRUNE rightapps-downloads\/fixture\/windows/);
109
+ assert.match(result.stdout, /PRUNE rightapps-updates\/fixture\/windows/);
110
+ assert.doesNotMatch(result.stdout, /UPLOAD .*Fixture-Setup\.exe/);
103
111
  });
104
112
 
105
113
  test("allows an installer-only patch without registering an updater manifest", () => {
@@ -113,6 +121,7 @@ test("allows an installer-only patch without registering an updater manifest", (
113
121
  assert.match(result.stdout, /NO patch updater artifacts/);
114
122
  assert.doesNotMatch(result.stdout, /\/v1\/admin\/apps\/patches/);
115
123
  assert.doesNotMatch(result.stdout, /UPLOAD private\//);
124
+ assert.match(result.stdout, /PRUNE rightapps-downloads\/fixture\/windows/);
116
125
  });
117
126
 
118
127
  test("refuses to publish without a valid tier", () => {
package/release.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { access, readFile } from "node:fs/promises";
3
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath, pathToFileURL } from "node:url";
5
6
  import { spawn, spawnSync } from "node:child_process";
@@ -9,7 +10,7 @@ const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
9
10
  const UPLOAD_LARGE = path.resolve(TOOL_ROOT, "upload-large.mjs");
10
11
  const SIGN_WINDOWS = path.resolve(TOOL_ROOT, "sign-windows.mjs");
11
12
  const SIGN_UPDATER = path.resolve(TOOL_ROOT, "sign-updater.mjs");
12
- const VERSION = "0.2.8";
13
+ const VERSION = "0.2.10";
13
14
  const TIERS = new Set(["patch", "update"]);
14
15
  const RIGHTKIT_EXPECTED = new Map([
15
16
  ["@rightkit/license", "^0.1.5"],
@@ -87,6 +88,7 @@ if (opts.doctor) {
87
88
  process.exit(0);
88
89
  }
89
90
 
91
+ const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
90
92
  const mode = opts.upload ? "publish" : "package";
91
93
  const command = target.package;
92
94
  if (!command) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
@@ -141,6 +143,7 @@ if (opts.upload && !target.publish) {
141
143
  }
142
144
 
143
145
  console.log(`right-release: done (${Date.now() - started}ms)`);
146
+ releaseLock.release();
144
147
 
145
148
  function usage(code, message) {
146
149
  if (message) console.error(`right-release: ${message}\n`);
@@ -178,8 +181,8 @@ async function validateRightKitPackageContract(root, appName) {
178
181
  const packageJsonPath = path.join(root, "package.json");
179
182
  const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
180
183
  const scripts = pkg.scripts ?? {};
181
- if (JSON.stringify(scripts).includes("../tools/right-release")) {
182
- fail(`${appName ?? pkg.name ?? "app"} package.json must call the right-release bin, not ../tools/right-release/*`);
184
+ if (JSON.stringify(scripts).includes("../tools/right-release") || JSON.stringify(scripts).includes("../tools/rightkit/packages/release")) {
185
+ fail(`${appName ?? pkg.name ?? "app"} package.json must call the right-release bin, not parent-workspace release source`);
183
186
  }
184
187
  const rightKitDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
185
188
  for (const [name, specifier] of Object.entries(rightKitDeps).filter(([name]) => name.startsWith("@rightkit/"))) {
@@ -249,19 +252,105 @@ function run(cmd, runArgs, cwd, env = {}, options = {}) {
249
252
  });
250
253
  }
251
254
 
255
+ function acquireReleaseLock(root, app, platform, tier) {
256
+ const lockDir = path.join(root, ".cache", "right-release");
257
+ const lockPath = path.join(lockDir, `${platform}.lock.json`);
258
+ mkdirSync(lockDir, { recursive: true });
259
+ const existing = readLock(lockPath);
260
+ if (existing?.pid && isRightReleaseProcess(existing.pid)) {
261
+ if (opts.dryRun) {
262
+ console.log(`dry-run: would kill previous ${app} ${platform} release pid=${existing.pid}`);
263
+ } else {
264
+ console.error(`right-release: killing previous ${app} ${platform} release pid=${existing.pid}`);
265
+ killProcessTree(existing.pid);
266
+ }
267
+ } else if (existing?.pid) {
268
+ console.error(`right-release: removing stale ${app} ${platform} release lock pid=${existing.pid}`);
269
+ }
270
+ const lock = {
271
+ pid: process.pid,
272
+ app,
273
+ platform,
274
+ tier,
275
+ startedAt: new Date().toISOString(),
276
+ cwd: root,
277
+ argv: process.argv,
278
+ };
279
+ writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
280
+ let released = false;
281
+ const release = () => {
282
+ if (released) return;
283
+ released = true;
284
+ const current = readLock(lockPath);
285
+ if (current?.pid === process.pid) {
286
+ try {
287
+ unlinkSync(lockPath);
288
+ } catch {
289
+ // already gone
290
+ }
291
+ }
292
+ };
293
+ process.once("exit", release);
294
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
295
+ process.once(signal, () => {
296
+ release();
297
+ process.exit(signal === "SIGINT" ? 130 : 143);
298
+ });
299
+ }
300
+ return { release };
301
+ }
302
+
303
+ function readLock(lockPath) {
304
+ if (!existsSync(lockPath)) return null;
305
+ try {
306
+ return JSON.parse(readFileSync(lockPath, "utf8"));
307
+ } catch {
308
+ return null;
309
+ }
310
+ }
311
+
312
+ function isRightReleaseProcess(pid) {
313
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
314
+ if (!isProcessAlive(Number(pid))) return false;
315
+ const commandLine = processCommandLine(Number(pid));
316
+ return /(?:right-release|release\.mjs)/i.test(commandLine);
317
+ }
318
+
319
+ function isProcessAlive(pid) {
320
+ try {
321
+ process.kill(pid, 0);
322
+ return true;
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+
328
+ function processCommandLine(pid) {
329
+ if (process.platform === "win32") {
330
+ const result = spawnSync("powershell", [
331
+ "-NoProfile",
332
+ "-Command",
333
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${Number(pid)}"; if ($p) { $p.CommandLine }`,
334
+ ], { encoding: "utf8" });
335
+ return result.stdout ?? "";
336
+ }
337
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
338
+ return result.stdout ?? "";
339
+ }
340
+
252
341
  function killProcessTree(pid) {
253
342
  if (!pid) return;
254
343
  if (process.platform === "win32") {
255
344
  spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
256
345
  return;
257
346
  }
347
+ const children = spawnSync("pgrep", ["-P", String(pid)], { encoding: "utf8" });
348
+ for (const child of (children.stdout ?? "").split(/\s+/).filter(Boolean)) {
349
+ killProcessTree(Number(child));
350
+ }
258
351
  try {
259
- process.kill(-pid, "SIGKILL");
352
+ process.kill(pid, "SIGKILL");
260
353
  } catch {
261
- try {
262
- process.kill(pid, "SIGKILL");
263
- } catch {
264
- // already gone
265
- }
354
+ // already gone
266
355
  }
267
356
  }
package/release.test.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawnSync } from "node:child_process";
@@ -18,7 +18,7 @@ function fixture({ signed = true, publish = false, packageJson } = {}) {
18
18
  name: "fixture",
19
19
  scripts: { "release:patch:win": "right-release --platform win --tier patch" },
20
20
  dependencies: { "@rightkit/license": "^0.1.5", "@rightkit/logs": "^0.1.3" },
21
- devDependencies: { "@rightkit/release": "^0.2.8" },
21
+ devDependencies: { "@rightkit/release": "^0.2.10" },
22
22
  },
23
23
  null,
24
24
  2,
@@ -47,12 +47,56 @@ function fixture({ signed = true, publish = false, packageJson } = {}) {
47
47
  return config;
48
48
  }
49
49
 
50
+ function lockFixture() {
51
+ const dir = mkdtempSync(path.join(os.tmpdir(), "right-release-lock-test-"));
52
+ const config = path.join(dir, "right-release.config.mjs");
53
+ writeFileSync(
54
+ path.join(dir, "package.json"),
55
+ JSON.stringify(
56
+ {
57
+ name: "fixture-lock",
58
+ scripts: { "release:patch:mac": "right-release --platform mac --tier patch" },
59
+ dependencies: { "@rightkit/license": "^0.1.5", "@rightkit/logs": "^0.1.3" },
60
+ devDependencies: { "@rightkit/release": "^0.2.10" },
61
+ },
62
+ null,
63
+ 2,
64
+ ),
65
+ );
66
+ writeFileSync(
67
+ config,
68
+ `export default ${JSON.stringify({
69
+ schema: 1,
70
+ app: "fixture-lock",
71
+ packageManager: "pnpm",
72
+ checks: [],
73
+ targets: {
74
+ mac: {
75
+ signed: true,
76
+ package: { cmd: "node", args: ["-e", "process.exit(0)"] },
77
+ artifacts: [],
78
+ hardening: [],
79
+ installer: { artifacts: [] },
80
+ updater: { artifacts: [] },
81
+ },
82
+ },
83
+ })};\n`,
84
+ );
85
+ return { dir, config };
86
+ }
87
+
50
88
  function run(config, ...args) {
51
89
  return spawnSync(process.execPath, [release, "--config", config, "--platform", "win", "--dry-run", ...args], {
52
90
  encoding: "utf8",
53
91
  });
54
92
  }
55
93
 
94
+ function runRaw(config, ...args) {
95
+ return spawnSync(process.execPath, [release, "--config", config, ...args], {
96
+ encoding: "utf8",
97
+ });
98
+ }
99
+
56
100
  test("rejects a release without an explicit entitlement tier", () => {
57
101
  const result = run(fixture());
58
102
  assert.notEqual(result.status, 0);
@@ -136,3 +180,12 @@ test("Windows update re-signs the updater artifact after Azure code signing", ()
136
180
  assert.ok(updaterAt > azureAt, result.stdout);
137
181
  assert.ok(publishAt > updaterAt, result.stdout);
138
182
  });
183
+
184
+ test("release lock is single-flight and is cleaned after success", () => {
185
+ const { dir, config } = lockFixture();
186
+ const lockPath = path.join(dir, ".cache", "right-release", "mac.lock.json");
187
+ const result = runRaw(config, "--platform", "mac", "--tier=patch");
188
+ assert.equal(result.status, 0, result.stderr);
189
+ assert.match(result.stdout, /right-release: done/);
190
+ assert.equal(existsSync(lockPath), false);
191
+ });
@@ -4,17 +4,17 @@ import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import test from "node:test";
6
6
 
7
- const workspace = path.resolve(new URL("../..", import.meta.url).pathname.replace(/^\/(\w:)/, "$1"));
7
+ const workspace = path.resolve(new URL("../../../..", import.meta.url).pathname.replace(/^\/(\w:)/, "$1"));
8
8
  const pubkey = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDI5Mzk1RjlGRjQ2NjI2MUQKUldRZEptYjBuMTg1S1VSUXlBdFM4WmtzaHArYko0U2hRMDVlSDJmSExVZG82Q0hoQ2srUlhqanAK";
9
9
  const licensePackage = "^0.1.5";
10
10
  const logsPackage = "^0.1.3";
11
- const releasePackage = "^0.2.8";
11
+ const releasePackage = "^0.2.10";
12
12
  const apps = [
13
- { key: "viewright", root: "viewright", tauri: "src-tauri/tauri.conf.json", host: "viewright.cc" },
14
- { key: "scraperight", root: "scraperight", tauri: "src-tauri/tauri.conf.json", host: "scraperight.cc" },
15
- { key: "heardright", root: "heardright/tauri-app-next", tauri: "src-tauri/tauri.conf.json", host: "heardright.app" },
16
- { key: "mailright", root: "mailright", tauri: "src-tauri/tauri.conf.json", host: "mailright.cc" },
17
- { key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json", host: "coderight.cc" },
13
+ { key: "viewright", root: "viewright", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/build-mac-notarized.sh"] },
14
+ { key: "scraperight", root: "scraperight", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["package.sh"] },
15
+ { key: "heardright", root: "heardright/tauri-app-next", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs", "scripts/publish-release.mjs"] },
16
+ { key: "mailright", root: "mailright", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs"] },
17
+ { key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs"] },
18
18
  ];
19
19
 
20
20
  for (const app of apps) {
@@ -29,6 +29,7 @@ for (const app of apps) {
29
29
  if (rightKitDeps["@rightkit/logs"]) assert.equal(rightKitDeps["@rightkit/logs"], logsPackage);
30
30
  assert.equal(pkg.devDependencies?.["@rightkit/release"], releasePackage);
31
31
  assert(!JSON.stringify(pkg).includes("github:adrdsouza/claude#main&path:/tools/right-release"));
32
+ assert(!JSON.stringify(pkg).includes("github:adrdsouza/claude#main&path:/tools/rightkit/packages/release"));
32
33
  assert(!JSON.stringify(pkg).includes("git+https://github.com/adrdsouza/rightkit.git"));
33
34
  assert.equal(pkg.scripts["release:doctor"], "right-release doctor");
34
35
  assert.equal(pkg.scripts["release:mac"], "right-release --platform mac");
@@ -44,6 +45,7 @@ for (const app of apps) {
44
45
  assert.equal(pkg.scripts["deps:check"], "right-release deps --check");
45
46
  assert.equal(pkg.scripts["deps:update"], "right-release deps --update");
46
47
  assert.ok(!JSON.stringify(pkg.scripts).includes("../tools/right-release"), `${app.key} scripts must not depend on the parent Claude workspace`);
48
+ assert.ok(!JSON.stringify(pkg.scripts).includes("../tools/rightkit/packages/release"), `${app.key} scripts must not depend on the parent Claude workspace`);
47
49
 
48
50
  const config = (await import(`${pathToFileURL(path.join(root, "right-release.config.mjs"))}?contract=${Date.now()}`)).default;
49
51
  assert.equal(config.app, app.key);
@@ -63,6 +65,7 @@ for (const app of apps) {
63
65
  assert.match(updater.key, /\/updates\/(mac|windows)\/current\//, `${app.key} ${platform} updaters must replace the stable current R2 object`);
64
66
  }
65
67
  }
68
+ assert.match(config.targets.mac.package.args.join(" "), /mac:dmg:notarized|--notarize/, `${app.key} publish packaging must use the notarized macOS lane`);
66
69
  assert.ok(config.targets.win.sign.files.length);
67
70
  const winUpdaterFiles = new Set(config.targets.win.updater.artifacts.map((artifact) => artifact.file));
68
71
  for (const signed of config.targets.win.sign.files) assert.ok(winUpdaterFiles.has(signed), `${signed} must be both Azure-signed and updater-signed`);
@@ -70,10 +73,13 @@ for (const app of apps) {
70
73
  const tauri = JSON.parse(readFileSync(path.join(root, app.tauri), "utf8"));
71
74
  assert.equal(tauri.plugins.updater.pubkey, pubkey);
72
75
  assert.deepEqual(tauri.plugins.updater.endpoints, [
73
- `https://${app.host}/releases/patches.json`,
74
- `https://${app.host}/releases/latest.json`,
76
+ `https://api.spoares.com/v1/apps/${app.key}/releases/latest.json?platform={{target}}`,
75
77
  ]);
76
78
  assert.equal(tauri.bundle.createUpdaterArtifacts, true);
79
+ for (const releaseFile of app.releaseFiles) {
80
+ const source = readFileSync(path.join(root, releaseFile), "utf8");
81
+ assert.doesNotMatch(source, /(?:\.\.\/)+tools\/right-release|tools\/right-release\//, `${app.key} ${releaseFile} must consume the installed package`);
82
+ }
77
83
  });
78
84
  }
79
85
 
package/upload-large.mjs CHANGED
File without changes