@rightkit/release 0.2.9 → 0.2.11

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.
@@ -16,6 +16,7 @@ const commands = new Map([
16
16
  ["sign-windows", "sign-windows.mjs"],
17
17
  ["sign-updater", "sign-updater.mjs"],
18
18
  ["create-mac-updater", "create-mac-updater.mjs"],
19
+ ["mirror-root-artifact", "mirror-root-artifact.mjs"],
19
20
  ]);
20
21
 
21
22
  const args = process.argv.slice(2);
@@ -39,6 +40,8 @@ if (first === "--version" || first === "-v") {
39
40
  run("release.mjs", rest.includes("--upload") || rest.includes("--publish") ? rest : [...rest, "--upload"]);
40
41
  } else if (first === "lsclean") {
41
42
  runBinary("bash", [path.join(packageRoot, "lsclean.sh"), ...args.slice(1)]);
43
+ } else if (first === "generate-dmg-background") {
44
+ runBinary("python3", [path.join(packageRoot, "generate-dmg-background.py"), ...args.slice(1)]);
42
45
  } else if (commands.has(first)) {
43
46
  run(commands.get(first), args.slice(1));
44
47
  } else if (first.startsWith("-")) {
@@ -102,6 +105,9 @@ Commands:
102
105
  deps --check|--audit|--update Shared dependency lane
103
106
  hardening <artifact...> Run the Right Suite hardening scan
104
107
  lsclean <AppName.app> Clear macOS LaunchServices duplicates
108
+ generate-dmg-background <options> Generate the branded multi-resolution DMG background
109
+ mirror-root-artifact --file <path> --package-root <dir>
110
+ Persist a worktree artifact in the primary repo root
105
111
 
106
112
  Direct flags are treated as: right-release release <flags>.
107
113
  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,56 @@
1
+ #!/usr/bin/env node
2
+ import { copyFileSync, mkdirSync, realpathSync, renameSync, rmSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ import path from "node:path";
5
+
6
+ const args = parseArgs(process.argv.slice(2));
7
+ if (!args.file || !args.packageRoot) {
8
+ console.error("Usage: right-release mirror-root-artifact --file <artifact> --package-root <dir> [--name <filename>]");
9
+ process.exit(2);
10
+ }
11
+
12
+ const packageRoot = realpathSync(path.resolve(args.packageRoot));
13
+ const source = path.resolve(packageRoot, args.file);
14
+ const name = args.name || path.basename(source);
15
+ const repoRoot = git(packageRoot, "rev-parse", "--show-toplevel");
16
+ const commonDir = git(packageRoot, "rev-parse", "--path-format=absolute", "--git-common-dir");
17
+ const canonicalRepoRoot = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : repoRoot;
18
+ const relativePackageRoot = path.relative(repoRoot, packageRoot);
19
+ const destinations = new Set([
20
+ path.join(packageRoot, name),
21
+ path.join(canonicalRepoRoot, relativePackageRoot, name),
22
+ ]);
23
+
24
+ for (const destination of destinations) {
25
+ if (path.resolve(destination) === source) continue;
26
+ atomicCopy(source, destination);
27
+ }
28
+
29
+ const canonicalArtifact = path.join(canonicalRepoRoot, relativePackageRoot, name);
30
+ console.log(`right-release: repository-root artifact -> ${canonicalArtifact}`);
31
+
32
+ function git(cwd, ...commandArgs) {
33
+ return execFileSync("git", commandArgs, { cwd, encoding: "utf8" }).trim();
34
+ }
35
+
36
+ function atomicCopy(from, to) {
37
+ mkdirSync(path.dirname(to), { recursive: true });
38
+ const temporary = `${to}.tmp-${process.pid}`;
39
+ rmSync(temporary, { force: true });
40
+ try {
41
+ copyFileSync(from, temporary);
42
+ renameSync(temporary, to);
43
+ } finally {
44
+ rmSync(temporary, { force: true });
45
+ }
46
+ }
47
+
48
+ function parseArgs(values) {
49
+ const parsed = {};
50
+ for (let i = 0; i < values.length; i += 1) {
51
+ const key = values[i];
52
+ if (!key.startsWith("--")) continue;
53
+ parsed[key.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = values[++i];
54
+ }
55
+ return parsed;
56
+ }
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+
8
+ const helper = new URL("./mirror-root-artifact.mjs", import.meta.url);
9
+
10
+ test("mirrors a nested package artifact from a linked worktree into both package roots", () => {
11
+ const temp = mkdtempSync(path.join(os.tmpdir(), "right-release-mirror-"));
12
+ const main = path.join(temp, "suite");
13
+ const worktree = path.join(temp, "release-worktree");
14
+ try {
15
+ mkdirSync(path.join(main, "apps", "desktop"), { recursive: true });
16
+ git(temp, "init", "suite");
17
+ git(main, "config", "user.email", "test@example.com");
18
+ git(main, "config", "user.name", "Right Release Test");
19
+ writeFileSync(path.join(main, "tracked"), "fixture\n");
20
+ git(main, "add", "tracked");
21
+ git(main, "commit", "-m", "fixture");
22
+ git(main, "worktree", "add", worktree, "-b", "release-test");
23
+
24
+ const packageRoot = path.join(worktree, "apps", "desktop");
25
+ const source = path.join(packageRoot, "target", "App_1.0.0.dmg");
26
+ mkdirSync(path.dirname(source), { recursive: true });
27
+ writeFileSync(source, "signed-dmg-fixture");
28
+
29
+ execFileSync(process.execPath, [helper.pathname, "--file", source, "--package-root", packageRoot], { encoding: "utf8" });
30
+
31
+ assert.equal(readFileSync(path.join(packageRoot, "App_1.0.0.dmg"), "utf8"), "signed-dmg-fixture");
32
+ assert.equal(readFileSync(path.join(main, "apps", "desktop", "App_1.0.0.dmg"), "utf8"), "signed-dmg-fixture");
33
+ } finally {
34
+ rmSync(temp, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ test("copies a main-worktree artifact into its package root", () => {
39
+ const temp = mkdtempSync(path.join(os.tmpdir(), "right-release-mirror-main-"));
40
+ try {
41
+ git(temp, "init", "app");
42
+ const root = path.join(temp, "app");
43
+ const source = path.join(root, "target", "App_2.0.0.dmg");
44
+ mkdirSync(path.dirname(source), { recursive: true });
45
+ writeFileSync(source, "main-worktree-dmg");
46
+
47
+ execFileSync(process.execPath, [helper.pathname, "--file", source, "--package-root", root], { encoding: "utf8" });
48
+
49
+ assert.equal(readFileSync(path.join(root, "App_2.0.0.dmg"), "utf8"), "main-worktree-dmg");
50
+ } finally {
51
+ rmSync(temp, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ function git(cwd, ...args) {
56
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
57
+ }
@@ -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.9",
3
+ "version": "0.2.11",
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
@@ -10,7 +10,7 @@ const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
10
10
  const UPLOAD_LARGE = path.resolve(TOOL_ROOT, "upload-large.mjs");
11
11
  const SIGN_WINDOWS = path.resolve(TOOL_ROOT, "sign-windows.mjs");
12
12
  const SIGN_UPDATER = path.resolve(TOOL_ROOT, "sign-updater.mjs");
13
- const VERSION = "0.2.9";
13
+ const VERSION = "0.2.11";
14
14
  const TIERS = new Set(["patch", "update"]);
15
15
  const RIGHTKIT_EXPECTED = new Map([
16
16
  ["@rightkit/license", "^0.1.5"],
@@ -181,8 +181,8 @@ async function validateRightKitPackageContract(root, appName) {
181
181
  const packageJsonPath = path.join(root, "package.json");
182
182
  const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
183
183
  const scripts = pkg.scripts ?? {};
184
- if (JSON.stringify(scripts).includes("../tools/right-release")) {
185
- 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`);
186
186
  }
187
187
  const rightKitDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
188
188
  for (const [name, specifier] of Object.entries(rightKitDeps).filter(([name]) => name.startsWith("@rightkit/"))) {
package/release.test.mjs CHANGED
@@ -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.9" },
21
+ devDependencies: { "@rightkit/release": "^0.2.11" },
22
22
  },
23
23
  null,
24
24
  2,
@@ -57,7 +57,7 @@ function lockFixture() {
57
57
  name: "fixture-lock",
58
58
  scripts: { "release:patch:mac": "right-release --platform mac --tier patch" },
59
59
  dependencies: { "@rightkit/license": "^0.1.5", "@rightkit/logs": "^0.1.3" },
60
- devDependencies: { "@rightkit/release": "^0.2.9" },
60
+ devDependencies: { "@rightkit/release": "^0.2.11" },
61
61
  },
62
62
  null,
63
63
  2,
@@ -1,20 +1,20 @@
1
1
  import assert from "node:assert/strict";
2
- import { readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  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.9";
11
+ const releasePackage = "^0.2.11";
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");
@@ -43,7 +44,9 @@ for (const app of apps) {
43
44
  assert.equal(pkg.scripts["publish:update:win"], "right-release publish --platform win --tier update");
44
45
  assert.equal(pkg.scripts["deps:check"], "right-release deps --check");
45
46
  assert.equal(pkg.scripts["deps:update"], "right-release deps --update");
47
+ assert.ok(!Object.entries(pkg.scripts).some(([name, command]) => /mac|dmg/i.test(name) && /unsigned|--no-sign/i.test(command)), `${app.key} must not expose an unsigned macOS DMG mode`);
46
48
  assert.ok(!JSON.stringify(pkg.scripts).includes("../tools/right-release"), `${app.key} scripts must not depend on the parent Claude workspace`);
49
+ assert.ok(!JSON.stringify(pkg.scripts).includes("../tools/rightkit/packages/release"), `${app.key} scripts must not depend on the parent Claude workspace`);
47
50
 
48
51
  const config = (await import(`${pathToFileURL(path.join(root, "right-release.config.mjs"))}?contract=${Date.now()}`)).default;
49
52
  assert.equal(config.app, app.key);
@@ -63,6 +66,10 @@ for (const app of apps) {
63
66
  assert.match(updater.key, /\/updates\/(mac|windows)\/current\//, `${app.key} ${platform} updaters must replace the stable current R2 object`);
64
67
  }
65
68
  }
69
+ assert.match(config.targets.mac.package.args.join(" "), /mac:dmg:notarized|--notarize/, `${app.key} publish packaging must use the notarized macOS lane`);
70
+ for (const installer of config.targets.mac.installer.artifacts) {
71
+ assert.equal(path.dirname(installer.file), ".", `${app.key} macOS installer must be copied to the app package root before upload`);
72
+ }
66
73
  assert.ok(config.targets.win.sign.files.length);
67
74
  const winUpdaterFiles = new Set(config.targets.win.updater.artifacts.map((artifact) => artifact.file));
68
75
  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 +77,17 @@ for (const app of apps) {
70
77
  const tauri = JSON.parse(readFileSync(path.join(root, app.tauri), "utf8"));
71
78
  assert.equal(tauri.plugins.updater.pubkey, pubkey);
72
79
  assert.deepEqual(tauri.plugins.updater.endpoints, [
73
- `https://${app.host}/releases/patches.json`,
74
- `https://${app.host}/releases/latest.json`,
80
+ `https://api.spoares.com/v1/apps/${app.key}/releases/latest.json?platform={{target}}`,
75
81
  ]);
76
82
  assert.equal(tauri.bundle.createUpdaterArtifacts, true);
83
+ for (const releaseFile of app.releaseFiles) {
84
+ const source = readFileSync(path.join(root, releaseFile), "utf8");
85
+ assert.doesNotMatch(source, /(?:\.\.\/)+tools\/right-release|tools\/right-release\//, `${app.key} ${releaseFile} must consume the installed package`);
86
+ assert.match(source, /right-release["']?,?\s*["']mirror-root-artifact|right-release mirror-root-artifact/, `${app.key} ${releaseFile} must mirror the final DMG to the canonical package root`);
87
+ }
88
+ for (const forbidden of ["scripts/build-mac-unsigned.sh", "package-unsigned.sh"]) {
89
+ assert.equal(existsSync(path.join(root, forbidden)), false, `${app.key} must not ship ${forbidden}`);
90
+ }
77
91
  });
78
92
  }
79
93
 
package/upload-large.mjs CHANGED
File without changes