@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.
- package/cli/right-release.mjs +6 -0
- package/deps.mjs +0 -0
- package/generate-dmg-background.py +53 -0
- package/lsclean.sh +0 -0
- package/mirror-root-artifact.mjs +56 -0
- package/mirror-root-artifact.test.mjs +57 -0
- package/notary-auth.mjs +21 -0
- package/notary-auth.test.mjs +30 -0
- package/package.json +9 -3
- package/prune-r2.mjs +56 -0
- package/prune-r2.test.mjs +12 -0
- package/publish-update.mjs +42 -2
- package/publish-update.test.mjs +10 -1
- package/release.mjs +3 -3
- package/release.test.mjs +2 -2
- package/right-suite-contract.test.mjs +24 -10
- package/upload-large.mjs +0 -0
package/cli/right-release.mjs
CHANGED
|
@@ -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
|
+
}
|
package/notary-auth.mjs
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
+
});
|
package/publish-update.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
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);
|
package/publish-update.test.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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("
|
|
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.
|
|
11
|
+
const releasePackage = "^0.2.11";
|
|
12
12
|
const apps = [
|
|
13
|
-
{ key: "viewright", root: "viewright", tauri: "src-tauri/tauri.conf.json",
|
|
14
|
-
{ key: "scraperight", root: "scraperight", tauri: "src-tauri/tauri.conf.json",
|
|
15
|
-
{ key: "heardright", root: "heardright/tauri-app-next", tauri: "src-tauri/tauri.conf.json",
|
|
16
|
-
{ key: "mailright", root: "mailright", tauri: "src-tauri/tauri.conf.json",
|
|
17
|
-
{ key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json",
|
|
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
|
|
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
|