@rightkit/release 0.2.0
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 +108 -0
- package/create-mac-updater.mjs +54 -0
- package/create-mac-updater.test.mjs +13 -0
- package/deps.mjs +89 -0
- package/hardeningscan.mjs +116 -0
- package/lsclean.sh +28 -0
- package/package.json +24 -0
- package/publish-update.mjs +148 -0
- package/publish-update.test.mjs +101 -0
- package/release.mjs +238 -0
- package/release.test.mjs +93 -0
- package/right-suite-contract.test.mjs +83 -0
- package/sign-updater.mjs +55 -0
- package/sign-updater.test.mjs +12 -0
- package/sign-windows.mjs +133 -0
- package/upload-large.mjs +74 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
const publisher = fileURLToPath(new URL("./publish-update.mjs", import.meta.url));
|
|
10
|
+
|
|
11
|
+
function fixture() {
|
|
12
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "right-publish-test-"));
|
|
13
|
+
const config = path.join(dir, "right-release.config.mjs");
|
|
14
|
+
writeFileSync(config, `export default ${JSON.stringify({
|
|
15
|
+
schema: 1,
|
|
16
|
+
app: "fixture",
|
|
17
|
+
version: "1.2.3",
|
|
18
|
+
targets: {
|
|
19
|
+
win: {
|
|
20
|
+
installer: { artifacts: [{ file: "Fixture-Setup.exe", key: "fixture/installers/windows/current/Fixture-Setup.exe" }] },
|
|
21
|
+
updater: {
|
|
22
|
+
artifacts: [
|
|
23
|
+
{ file: "Fixture.exe", signature: "Fixture.exe.sig", platform: "windows-x86_64", key: "fixture/updates/windows/current/Fixture.exe" },
|
|
24
|
+
{ file: "Fixture.exe", signature: "Fixture.exe.sig", platform: "windows-arm64", key: "fixture/updates/windows/current/Fixture.exe" },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
})};\n`);
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function versionedFixture() {
|
|
34
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "right-publish-test-"));
|
|
35
|
+
const config = path.join(dir, "right-release.config.mjs");
|
|
36
|
+
writeFileSync(config, `export default ${JSON.stringify({
|
|
37
|
+
schema: 1,
|
|
38
|
+
app: "fixture",
|
|
39
|
+
version: "1.2.3",
|
|
40
|
+
targets: {
|
|
41
|
+
win: {
|
|
42
|
+
installer: { artifacts: [{ file: "Fixture-Setup.exe", key: "fixture/installers/windows/1.2.3/Fixture-Setup.exe" }] },
|
|
43
|
+
updater: {
|
|
44
|
+
artifacts: [
|
|
45
|
+
{ file: "Fixture.exe", signature: "Fixture.exe.sig", platform: "windows-x86_64", key: "fixture/1.2.3/windows/Fixture.exe" },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
})};\n`);
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function run(tier) {
|
|
55
|
+
const config = fixture();
|
|
56
|
+
return spawnSync(process.execPath, [publisher, "--config", config, "--platform", "win", "--dry-run"], {
|
|
57
|
+
encoding: "utf8",
|
|
58
|
+
env: { ...process.env, RIGHT_RELEASE_TIER: tier },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("routes patch manifests to the public patch registration endpoint", () => {
|
|
63
|
+
const result = run("patch");
|
|
64
|
+
assert.equal(result.status, 0, result.stderr);
|
|
65
|
+
const updaterUpload = result.stdout.indexOf("UPLOAD public/fixture/updates/windows/current/Fixture.exe");
|
|
66
|
+
const installerUpload = result.stdout.indexOf("UPLOAD public/fixture/installers/windows/current/Fixture-Setup.exe");
|
|
67
|
+
const registration = result.stdout.indexOf("/v1/admin/apps/patches");
|
|
68
|
+
assert.ok(updaterUpload >= 0, "patch must upload a public updater");
|
|
69
|
+
assert.ok(installerUpload >= 0, "patch must upload a public installer");
|
|
70
|
+
assert.ok(updaterUpload < installerUpload, "patch should upload the updater before replacing the public installer");
|
|
71
|
+
assert.ok(installerUpload < registration, "patch should register only after uploads complete");
|
|
72
|
+
assert.equal(result.stdout.match(/UPLOAD public\/fixture\/updates\/windows\/current\/Fixture\.exe/g)?.length, 1);
|
|
73
|
+
assert.match(result.stdout, /pub-6c73208d46c245a9b4881d5e02f6b618\.r2\.dev\/fixture\/updates\/windows\/current\/Fixture\.exe/);
|
|
74
|
+
assert.match(result.stdout, /POST .*\/v1\/admin\/apps\/patches/);
|
|
75
|
+
assert.match(result.stdout, /"tier":"patch"/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("routes feature updates to the Pro-gated release endpoint", () => {
|
|
79
|
+
const result = run("update");
|
|
80
|
+
assert.equal(result.status, 0, result.stderr);
|
|
81
|
+
assert.match(result.stdout, /POST .*\/v1\/admin\/apps\/releases/);
|
|
82
|
+
assert.match(result.stdout, /"tier":"update"/);
|
|
83
|
+
assert.match(result.stdout, /UPLOAD private\/fixture\/updates\/windows\/current\/Fixture\.exe/);
|
|
84
|
+
assert.doesNotMatch(result.stdout, /Fixture-Setup\.exe/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("refuses to publish without a valid tier", () => {
|
|
88
|
+
const result = run("");
|
|
89
|
+
assert.notEqual(result.status, 0);
|
|
90
|
+
assert.match(result.stderr, /RIGHT_RELEASE_TIER.*patch\|update/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("refuses versioned R2 object keys that would accumulate old releases", () => {
|
|
94
|
+
const config = versionedFixture();
|
|
95
|
+
const result = spawnSync(process.execPath, [publisher, "--config", config, "--platform", "win", "--dry-run"], {
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
env: { ...process.env, RIGHT_RELEASE_TIER: "patch" },
|
|
98
|
+
});
|
|
99
|
+
assert.notEqual(result.status, 0);
|
|
100
|
+
assert.match(result.stderr, /current R2 object/);
|
|
101
|
+
});
|
package/release.mjs
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { access, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
|
|
9
|
+
const UPLOAD_LARGE = path.resolve(TOOL_ROOT, "upload-large.mjs");
|
|
10
|
+
const SIGN_WINDOWS = path.resolve(TOOL_ROOT, "sign-windows.mjs");
|
|
11
|
+
const SIGN_UPDATER = path.resolve(TOOL_ROOT, "sign-updater.mjs");
|
|
12
|
+
const VERSION = "0.2.0";
|
|
13
|
+
const TIERS = new Set(["patch", "update"]);
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const opts = {
|
|
17
|
+
config: "right-release.config.mjs",
|
|
18
|
+
platform: process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform,
|
|
19
|
+
upload: false,
|
|
20
|
+
install: false,
|
|
21
|
+
dryRun: false,
|
|
22
|
+
skipChecks: false,
|
|
23
|
+
skipHardening: false,
|
|
24
|
+
doctor: false,
|
|
25
|
+
tier: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
const arg = args[i];
|
|
30
|
+
if (arg === "--") continue;
|
|
31
|
+
else if (arg === "--config") opts.config = args[++i];
|
|
32
|
+
else if (arg === "--platform") opts.platform = args[++i];
|
|
33
|
+
else if (arg === "--upload" || arg === "--publish") opts.upload = true;
|
|
34
|
+
else if (arg === "--no-upload" || arg === "--package") opts.upload = false;
|
|
35
|
+
else if (arg === "--install") opts.install = true;
|
|
36
|
+
else if (arg === "--dry-run") opts.dryRun = true;
|
|
37
|
+
else if (arg === "--skip-checks") opts.skipChecks = true;
|
|
38
|
+
else if (arg === "--skip-hardening") opts.skipHardening = true;
|
|
39
|
+
else if (arg === "--doctor") opts.doctor = true;
|
|
40
|
+
else if (arg === "--tier") opts.tier = args[++i];
|
|
41
|
+
else if (arg.startsWith("--tier=")) opts.tier = arg.slice("--tier=".length);
|
|
42
|
+
else if (arg === "--version") {
|
|
43
|
+
console.log(VERSION);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
else if (arg === "-h" || arg === "--help") usage(0);
|
|
47
|
+
else usage(2, `unknown argument: ${arg}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!opts.doctor && !opts.tier) usage(2, "--tier is required (patch|update)");
|
|
51
|
+
if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
|
|
52
|
+
|
|
53
|
+
const configPath = path.resolve(opts.config);
|
|
54
|
+
const config = (await import(pathToFileURL(configPath))).default;
|
|
55
|
+
if (!config) usage(2, `config did not export default: ${configPath}`);
|
|
56
|
+
if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<missing>"} (expected 1)`);
|
|
57
|
+
|
|
58
|
+
const root = path.dirname(configPath);
|
|
59
|
+
const workdir = path.resolve(root, config.workdir ?? ".");
|
|
60
|
+
const target = config.targets?.[opts.platform];
|
|
61
|
+
if (!target) fail(`${config.app ?? "app"} has no ${opts.platform} release target`);
|
|
62
|
+
if (target.signed !== true) {
|
|
63
|
+
fail(`${config.app ?? "app"} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
|
|
64
|
+
}
|
|
65
|
+
if (opts.platform === "win" && !target.sign?.files?.length) {
|
|
66
|
+
fail(`${config.app ?? "app"} win must declare sign.files for the signed release pipeline`);
|
|
67
|
+
}
|
|
68
|
+
if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
|
|
69
|
+
if (opts.doctor) {
|
|
70
|
+
console.log(`right-release ${VERSION}`);
|
|
71
|
+
console.log(`config: ${configPath}`);
|
|
72
|
+
console.log(`app: ${config.app}`);
|
|
73
|
+
console.log(`platform: ${opts.platform}`);
|
|
74
|
+
console.log(`tier: ${opts.tier ?? "<required for release/publish>"}`);
|
|
75
|
+
console.log(`packageManager: ${config.packageManager}`);
|
|
76
|
+
console.log(`workdir: ${workdir}`);
|
|
77
|
+
console.log(`hardeningscan: ${HARDENING_SCAN}`);
|
|
78
|
+
if (target.sign?.files?.length) console.log(`sign: ${target.sign.files.join(", ")}`);
|
|
79
|
+
if (target.publishBlocked) console.log(`publishBlocked: ${target.publishBlocked}`);
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const mode = opts.upload ? "publish" : "package";
|
|
84
|
+
const command = target.package;
|
|
85
|
+
if (!command) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
|
|
86
|
+
if (opts.upload && !target.publish && !target.upload) {
|
|
87
|
+
fail(`${config.app ?? "app"} has no ${opts.platform} upload config`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const started = Date.now();
|
|
91
|
+
console.log(`right-release ${VERSION}: ${config.app ?? path.basename(root)} ${opts.platform} ${mode} tier=${opts.tier}`);
|
|
92
|
+
|
|
93
|
+
if (opts.install) await runInstall(config.packageManager, workdir);
|
|
94
|
+
if (!opts.skipChecks) {
|
|
95
|
+
for (const script of config.checks ?? []) await runPackageScript(config.packageManager, script, workdir);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await runCommand(command, root);
|
|
99
|
+
|
|
100
|
+
for (const rel of target.artifacts ?? []) {
|
|
101
|
+
await mustExist(path.resolve(root, rel), `missing release artifact: ${rel}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (opts.platform === "win" && target.sign?.files?.length) {
|
|
105
|
+
const files = target.sign.files.map((p) => path.resolve(root, p));
|
|
106
|
+
for (const file of files) await mustExist(file, `missing signing artifact: ${file}`);
|
|
107
|
+
await run("node", [SIGN_WINDOWS, ...(opts.dryRun ? ["--dry-run"] : []), ...files], root);
|
|
108
|
+
const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => path.resolve(root, artifact.file)))];
|
|
109
|
+
if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
|
|
110
|
+
await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (target.postSign) {
|
|
114
|
+
await runCommand(target.postSign, root);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!opts.skipHardening) {
|
|
118
|
+
const scanTargets = target.hardening ?? target.artifacts ?? [];
|
|
119
|
+
if (scanTargets.length) {
|
|
120
|
+
await run("node", [HARDENING_SCAN, ...scanTargets.map((p) => path.resolve(root, p))], root);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (opts.upload && target.publish) {
|
|
125
|
+
await runCommand(target.publish, root);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (opts.upload && !target.publish) {
|
|
129
|
+
for (const item of target.upload ?? []) {
|
|
130
|
+
const file = path.resolve(root, item.file);
|
|
131
|
+
await mustExist(file, `missing upload artifact: ${item.file}`);
|
|
132
|
+
await run("node", [UPLOAD_LARGE, file, item.key, item.bucket ?? "public"], root);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log(`right-release: done (${Date.now() - started}ms)`);
|
|
137
|
+
|
|
138
|
+
function usage(code, message) {
|
|
139
|
+
if (message) console.error(`right-release: ${message}\n`);
|
|
140
|
+
console.error("usage: node release.mjs --tier patch|update [--config right-release.config.mjs] [--platform mac|win] [--upload|--no-upload] [--install] [--dry-run] [--doctor]");
|
|
141
|
+
process.exit(code);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function fail(message) {
|
|
145
|
+
console.error(`right-release: ${message}`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function mustExist(file, message) {
|
|
150
|
+
if (opts.dryRun) return;
|
|
151
|
+
await access(file).catch(() => fail(message));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function runInstall(pm, cwd) {
|
|
155
|
+
if (pm === "pnpm") await run("pnpm", ["install", "--frozen-lockfile"], cwd);
|
|
156
|
+
else if (pm === "npm") await run("npm", ["ci"], cwd);
|
|
157
|
+
else fail(`unsupported package manager: ${pm}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function runPackageScript(pm, script, cwd) {
|
|
161
|
+
if (script === false) return;
|
|
162
|
+
await run(pm, ["run", script], cwd);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function runCommand(command, root) {
|
|
166
|
+
const cwd = path.resolve(root, command.cwd ?? ".");
|
|
167
|
+
await run(command.cmd, command.args ?? [], cwd, await commandEnv(command, root), command);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function commandEnv(command, root) {
|
|
171
|
+
const env = { ...(command.env ?? {}) };
|
|
172
|
+
for (const [name, rel] of Object.entries(command.envFiles ?? {})) {
|
|
173
|
+
if (opts.dryRun) continue;
|
|
174
|
+
env[name] = await readFile(path.resolve(root, expandEnv(rel)), "utf8");
|
|
175
|
+
}
|
|
176
|
+
return env;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function expandEnv(value) {
|
|
180
|
+
return String(value).replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function run(cmd, runArgs, cwd, env = {}, options = {}) {
|
|
184
|
+
const printable = `${cmd} ${runArgs.join(" ")}`.trim();
|
|
185
|
+
const releaseEnv = opts.tier ? { RIGHT_RELEASE_TIER: opts.tier } : {};
|
|
186
|
+
if (opts.dryRun) {
|
|
187
|
+
const timeout = options.timeoutMs ? ` timeout=${options.timeoutMs}ms` : "";
|
|
188
|
+
const tier = opts.tier ? `RIGHT_RELEASE_TIER=${opts.tier} ` : "";
|
|
189
|
+
console.log(`dry-run: (${cwd}) ${tier}${printable}${timeout}`);
|
|
190
|
+
return Promise.resolve();
|
|
191
|
+
}
|
|
192
|
+
const started = Date.now();
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const child = spawn(cmd, runArgs, {
|
|
195
|
+
cwd,
|
|
196
|
+
env: { ...process.env, ...releaseEnv, ...env },
|
|
197
|
+
stdio: "inherit",
|
|
198
|
+
shell: process.platform === "win32",
|
|
199
|
+
windowsHide: true,
|
|
200
|
+
});
|
|
201
|
+
const timer = options.timeoutMs
|
|
202
|
+
? setTimeout(() => {
|
|
203
|
+
killProcessTree(child.pid);
|
|
204
|
+
console.error(`right-release step timed out after ${Math.round(options.timeoutMs / 1000)}s: ${printable}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}, options.timeoutMs)
|
|
207
|
+
: null;
|
|
208
|
+
child.on("error", (error) => {
|
|
209
|
+
if (timer) clearTimeout(timer);
|
|
210
|
+
console.error(`right-release step failed to start: ${printable}`);
|
|
211
|
+
console.error(error.message);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
});
|
|
214
|
+
child.on("exit", (code) => {
|
|
215
|
+
if (timer) clearTimeout(timer);
|
|
216
|
+
console.log(`right-release step: ${printable} (${Date.now() - started}ms)`);
|
|
217
|
+
if (code !== 0) process.exit(code ?? 1);
|
|
218
|
+
resolve();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function killProcessTree(pid) {
|
|
224
|
+
if (!pid) return;
|
|
225
|
+
if (process.platform === "win32") {
|
|
226
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
process.kill(-pid, "SIGKILL");
|
|
231
|
+
} catch {
|
|
232
|
+
try {
|
|
233
|
+
process.kill(pid, "SIGKILL");
|
|
234
|
+
} catch {
|
|
235
|
+
// already gone
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
package/release.test.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
const release = fileURLToPath(new URL("./release.mjs", import.meta.url));
|
|
10
|
+
|
|
11
|
+
function fixture({ signed = true, publish = false } = {}) {
|
|
12
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "right-release-test-"));
|
|
13
|
+
const config = path.join(dir, "right-release.config.mjs");
|
|
14
|
+
writeFileSync(
|
|
15
|
+
config,
|
|
16
|
+
`export default ${JSON.stringify({
|
|
17
|
+
schema: 1,
|
|
18
|
+
app: "fixture",
|
|
19
|
+
packageManager: "pnpm",
|
|
20
|
+
checks: [],
|
|
21
|
+
targets: {
|
|
22
|
+
win: {
|
|
23
|
+
signed,
|
|
24
|
+
package: { cmd: "node", args: ["-e", "process.exit(0)"] },
|
|
25
|
+
...(publish ? { publish: { cmd: "node", args: ["publish-update.mjs"] } } : {}),
|
|
26
|
+
artifacts: [],
|
|
27
|
+
sign: { files: ["fixture.exe"] },
|
|
28
|
+
updater: { artifacts: [{ file: "fixture.exe", signature: "fixture.exe.sig", platform: "windows-x86_64", key: "fixture/fixture.exe" }] },
|
|
29
|
+
hardening: [],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
})};\n`,
|
|
33
|
+
);
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function run(config, ...args) {
|
|
38
|
+
return spawnSync(process.execPath, [release, "--config", config, "--platform", "win", "--dry-run", ...args], {
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("rejects a release without an explicit entitlement tier", () => {
|
|
44
|
+
const result = run(fixture());
|
|
45
|
+
assert.notEqual(result.status, 0);
|
|
46
|
+
assert.match(result.stderr, /--tier is required.*patch\|update/i);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("accepts patch and exposes it to the signed package command", () => {
|
|
50
|
+
const result = run(fixture(), "--tier=patch");
|
|
51
|
+
assert.equal(result.status, 0, result.stderr);
|
|
52
|
+
assert.match(result.stdout, /tier=patch/);
|
|
53
|
+
assert.match(result.stdout, /RIGHT_RELEASE_TIER=patch/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("accepts update as the Pro-gated feature tier", () => {
|
|
57
|
+
const result = run(fixture(), "--tier", "update");
|
|
58
|
+
assert.equal(result.status, 0, result.stderr);
|
|
59
|
+
assert.match(result.stdout, /tier=update/);
|
|
60
|
+
assert.match(result.stdout, /RIGHT_RELEASE_TIER=update/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejects an invalid entitlement tier", () => {
|
|
64
|
+
const result = run(fixture(), "--tier=minor");
|
|
65
|
+
assert.notEqual(result.status, 0);
|
|
66
|
+
assert.match(result.stderr, /invalid --tier.*patch\|update/i);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects targets that do not explicitly declare signed release output", () => {
|
|
70
|
+
const result = run(fixture({ signed: false }), "--tier=patch");
|
|
71
|
+
assert.notEqual(result.status, 0);
|
|
72
|
+
assert.match(result.stderr, /signed: true/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("publish runs the signed package step before the updater publication step", () => {
|
|
76
|
+
const result = run(fixture({ publish: true }), "--tier=update", "--upload");
|
|
77
|
+
assert.equal(result.status, 0, result.stderr);
|
|
78
|
+
const packageAt = result.stdout.indexOf("node -e process.exit(0)");
|
|
79
|
+
const publishAt = result.stdout.indexOf("node publish-update.mjs");
|
|
80
|
+
assert.ok(packageAt >= 0, result.stdout);
|
|
81
|
+
assert.ok(publishAt > packageAt, result.stdout);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("Windows re-signs the updater artifact after Azure code signing", () => {
|
|
85
|
+
const result = run(fixture({ publish: true }), "--tier=patch", "--upload");
|
|
86
|
+
assert.equal(result.status, 0, result.stderr);
|
|
87
|
+
const azureAt = result.stdout.indexOf("sign-windows.mjs");
|
|
88
|
+
const updaterAt = result.stdout.indexOf("sign-updater.mjs");
|
|
89
|
+
const publishAt = result.stdout.indexOf("publish-update.mjs");
|
|
90
|
+
assert.ok(azureAt >= 0, result.stdout);
|
|
91
|
+
assert.ok(updaterAt > azureAt, result.stdout);
|
|
92
|
+
assert.ok(publishAt > updaterAt, result.stdout);
|
|
93
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
const workspace = path.resolve(new URL("../..", import.meta.url).pathname.replace(/^\/(\w:)/, "$1"));
|
|
8
|
+
const pubkey = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDI5Mzk1RjlGRjQ2NjI2MUQKUldRZEptYjBuMTg1S1VSUXlBdFM4WmtzaHArYko0U2hRMDVlSDJmSExVZG82Q0hoQ2srUlhqanAK";
|
|
9
|
+
const releasePackage = "^0.2.0";
|
|
10
|
+
const apps = [
|
|
11
|
+
{ key: "viewright", root: "viewright", tauri: "src-tauri/tauri.conf.json", host: "viewright.cc" },
|
|
12
|
+
{ key: "scraperight", root: "scraperight", tauri: "src-tauri/tauri.conf.json", host: "scraperight.cc" },
|
|
13
|
+
{ key: "heardright", root: "heardright/tauri-app-next", tauri: "src-tauri/tauri.conf.json", host: "heardright.app" },
|
|
14
|
+
{ key: "mailright", root: "mailright", tauri: "src-tauri/tauri.conf.json", host: "mailright.cc" },
|
|
15
|
+
{ key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json", host: "coderight.cc" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
for (const app of apps) {
|
|
19
|
+
test(`${app.key} follows the signed tiered Right Release contract`, async () => {
|
|
20
|
+
const root = path.join(workspace, app.root);
|
|
21
|
+
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
22
|
+
assert.equal(pkg.devDependencies?.["@rightkit/release"], releasePackage);
|
|
23
|
+
assert(!JSON.stringify(pkg).includes("github:adrdsouza/claude#main&path:/tools/right-release"));
|
|
24
|
+
assert(!JSON.stringify(pkg).includes("git+https://github.com/adrdsouza/rightkit.git"));
|
|
25
|
+
assert.equal(pkg.scripts["release:doctor"], "right-release doctor");
|
|
26
|
+
assert.equal(pkg.scripts["release:mac"], "right-release --platform mac");
|
|
27
|
+
assert.equal(pkg.scripts["release:win"], "right-release --platform win");
|
|
28
|
+
assert.equal(pkg.scripts["release:patch:mac"], "right-release --platform mac --tier patch");
|
|
29
|
+
assert.equal(pkg.scripts["release:patch:win"], "right-release --platform win --tier patch");
|
|
30
|
+
assert.equal(pkg.scripts["release:update:mac"], "right-release --platform mac --tier update");
|
|
31
|
+
assert.equal(pkg.scripts["release:update:win"], "right-release --platform win --tier update");
|
|
32
|
+
assert.equal(pkg.scripts["publish:patch:mac"], "right-release publish --platform mac --tier patch");
|
|
33
|
+
assert.equal(pkg.scripts["publish:patch:win"], "right-release publish --platform win --tier patch");
|
|
34
|
+
assert.equal(pkg.scripts["publish:update:mac"], "right-release publish --platform mac --tier update");
|
|
35
|
+
assert.equal(pkg.scripts["publish:update:win"], "right-release publish --platform win --tier update");
|
|
36
|
+
assert.equal(pkg.scripts["deps:check"], "right-release deps --check");
|
|
37
|
+
assert.equal(pkg.scripts["deps:update"], "right-release deps --update");
|
|
38
|
+
assert.ok(!JSON.stringify(pkg.scripts).includes("../tools/right-release"), `${app.key} scripts must not depend on the parent Claude workspace`);
|
|
39
|
+
|
|
40
|
+
const config = (await import(`${pathToFileURL(path.join(root, "right-release.config.mjs"))}?contract=${Date.now()}`)).default;
|
|
41
|
+
assert.equal(config.app, app.key);
|
|
42
|
+
assert.ok(config.version);
|
|
43
|
+
for (const platform of ["mac", "win"]) {
|
|
44
|
+
const target = config.targets[platform];
|
|
45
|
+
assert.equal(target.signed, true);
|
|
46
|
+
assert.equal(target.upload, undefined, "generic uploads bypass tier manifest routing");
|
|
47
|
+
assert.equal(target.publish.cmd, "right-release");
|
|
48
|
+
assert.match(target.publish.args.join(" "), /^publish-update\b/);
|
|
49
|
+
assert.ok(target.updater.artifacts.length);
|
|
50
|
+
assert.ok(target.installer.artifacts.length, "patch releases must refresh the public installer");
|
|
51
|
+
for (const installer of target.installer.artifacts) {
|
|
52
|
+
assert.match(installer.key, /\/installers\/(mac|windows)\/current\//, `${app.key} ${platform} installers must replace the stable current R2 object`);
|
|
53
|
+
}
|
|
54
|
+
for (const updater of target.updater.artifacts) {
|
|
55
|
+
assert.match(updater.key, /\/updates\/(mac|windows)\/current\//, `${app.key} ${platform} updaters must replace the stable current R2 object`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
assert.ok(config.targets.win.sign.files.length);
|
|
59
|
+
const winUpdaterFiles = new Set(config.targets.win.updater.artifacts.map((artifact) => artifact.file));
|
|
60
|
+
for (const signed of config.targets.win.sign.files) assert.ok(winUpdaterFiles.has(signed), `${signed} must be both Azure-signed and updater-signed`);
|
|
61
|
+
|
|
62
|
+
const tauri = JSON.parse(readFileSync(path.join(root, app.tauri), "utf8"));
|
|
63
|
+
assert.equal(tauri.plugins.updater.pubkey, pubkey);
|
|
64
|
+
assert.deepEqual(tauri.plugins.updater.endpoints, [
|
|
65
|
+
`https://${app.host}/releases/patches.json`,
|
|
66
|
+
`https://${app.host}/releases/latest.json`,
|
|
67
|
+
]);
|
|
68
|
+
assert.equal(tauri.bundle.createUpdaterArtifacts, true);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test("RightApps brand hosts expose app-keyed update manifest proxies", () => {
|
|
73
|
+
for (const app of apps) {
|
|
74
|
+
const siteRoot = path.join(workspace, "rightapps", app.key);
|
|
75
|
+
for (const route of ["patches", "latest"]) {
|
|
76
|
+
const routeFile = path.join(siteRoot, "src", "routes", "releases", `${route}.json`, "index.ts");
|
|
77
|
+
const source = readFileSync(routeFile, "utf8");
|
|
78
|
+
assert.match(source, new RegExp(`APP_KEY\\s*=\\s*["']${app.key}["']`), `${app.key} ${route}.json proxy must set the app key`);
|
|
79
|
+
assert.match(source, /RIGHTAPPS_API_URL/, `${app.key} ${route}.json proxy must use the configurable RightApps API base`);
|
|
80
|
+
assert.match(source, /x-app-key/i, `${app.key} ${route}.json proxy must forward the app key header`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
package/sign-updater.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const files = [];
|
|
9
|
+
let dryRun = false;
|
|
10
|
+
for (let i = 0; i < args.length; i++) {
|
|
11
|
+
if (args[i] === "--file") files.push(path.resolve(args[++i]));
|
|
12
|
+
else if (args[i] === "--dry-run") dryRun = true;
|
|
13
|
+
else fail(`unknown argument: ${args[i]}`);
|
|
14
|
+
}
|
|
15
|
+
if (!files.length) fail("at least one --file is required");
|
|
16
|
+
|
|
17
|
+
const env = { ...process.env };
|
|
18
|
+
if (!dryRun && !env.TAURI_SIGNING_PRIVATE_KEY) {
|
|
19
|
+
if (process.platform === "win32") {
|
|
20
|
+
const key = path.join(env.APPDATA || "", "ViewRight", "updater.key");
|
|
21
|
+
if (!existsSync(key)) fail(`shared updater key not found: ${key}`);
|
|
22
|
+
env.TAURI_SIGNING_PRIVATE_KEY = readFileSync(key, "utf8");
|
|
23
|
+
} else if (process.platform === "darwin") {
|
|
24
|
+
try {
|
|
25
|
+
env.TAURI_SIGNING_PRIVATE_KEY = execFileSync(
|
|
26
|
+
"security",
|
|
27
|
+
["find-generic-password", "-a", os.userInfo().username, "-s", "rightsuite-updater-key", "-w"],
|
|
28
|
+
{ encoding: "utf8" },
|
|
29
|
+
).trim();
|
|
30
|
+
} catch {
|
|
31
|
+
fail("shared updater key is missing from Keychain service rightsuite-updater-key");
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
fail("TAURI_SIGNING_PRIVATE_KEY is required on this platform");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD ??= "";
|
|
38
|
+
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
if (!dryRun && !existsSync(file)) fail(`updater artifact not found: ${file}`);
|
|
41
|
+
rmSync(`${file}.sig`, { force: true });
|
|
42
|
+
console.log(`${dryRun ? "[dry-run] " : ""}pnpm exec tauri signer sign ${file}`);
|
|
43
|
+
if (dryRun) continue;
|
|
44
|
+
const result = spawnSync("pnpm", ["exec", "tauri", "signer", "sign", file], {
|
|
45
|
+
cwd: process.cwd(),
|
|
46
|
+
env,
|
|
47
|
+
stdio: "inherit",
|
|
48
|
+
});
|
|
49
|
+
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fail(message) {
|
|
53
|
+
console.error(`sign-updater: ${message}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
|
|
6
|
+
const helper = fileURLToPath(new URL("./sign-updater.mjs", import.meta.url));
|
|
7
|
+
|
|
8
|
+
test("dry-run signs the final OS-signed artifact with Tauri minisign", () => {
|
|
9
|
+
const result = spawnSync(process.execPath, [helper, "--file", "Fixture.exe", "--dry-run"], { encoding: "utf8" });
|
|
10
|
+
assert.equal(result.status, 0, result.stderr);
|
|
11
|
+
assert.match(result.stdout, /tauri signer sign .*Fixture\.exe/);
|
|
12
|
+
});
|