@rightkit/release 0.2.39 → 0.2.40

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.
@@ -53,15 +53,28 @@ test("allows release invocation from the primary Git checkout", () => {
53
53
  assert.doesNotThrow(() => assertPrimaryReleaseCheckout(primary));
54
54
  });
55
55
 
56
- test("reports dirty files that can change the packaged app", () => {
56
+ test("rejects a detached HEAD even in the primary Git checkout", () => {
57
+ const primary = makeRepo();
58
+ git(primary, "checkout", "--detach");
59
+
60
+ assert.throws(
61
+ () => assertPrimaryReleaseCheckout(primary),
62
+ /detached HEAD is forbidden/i,
63
+ );
64
+ });
65
+
66
+ test("reports a dirty tracked file declared as a build input", () => {
57
67
  const primary = makeRepo();
58
68
  writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
69
+
70
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs), ["src/app.js"]);
71
+ });
72
+
73
+ test("reports an untracked file declared as a build input", () => {
74
+ const primary = makeRepo();
59
75
  writeFileSync(path.join(primary, "src", "new-runtime.js"), "export {};\n");
60
76
 
61
- assert.deepEqual(dirtyBuildInputs(primary, buildInputs), [
62
- "src/app.js",
63
- "src/new-runtime.js",
64
- ]);
77
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs), ["src/new-runtime.js"]);
65
78
  });
66
79
 
67
80
  test("ignores dirty files outside the packaged app input set", () => {
@@ -73,6 +86,21 @@ test("ignores dirty files outside the packaged app input set", () => {
73
86
  assert.deepEqual(dirtyBuildInputs(primary, buildInputs), []);
74
87
  });
75
88
 
89
+ test("required release metadata cannot be hidden by build-input exclusions", () => {
90
+ const primary = makeRepo();
91
+ writeFileSync(path.join(primary, "package.json"), "{\"version\":\"2.0.0\"}\n");
92
+
93
+ assert.deepEqual(dirtyBuildInputs(primary, {
94
+ ...buildInputs,
95
+ exclude: [...buildInputs.exclude, "package.json"],
96
+ required: ["package.json"],
97
+ }), ["package.json"]);
98
+ assert.deepEqual(dirtyBuildInputs(primary, {
99
+ include: ["package.json"],
100
+ required: ["package.json"],
101
+ }), ["package.json"]);
102
+ });
103
+
76
104
  test("checks only build-relevant fields in mixed-purpose JSON manifests", () => {
77
105
  const primary = makeRepo();
78
106
  writeFileSync(path.join(primary, "package.json"), `${JSON.stringify({ scripts: { bakeoff: "node eval.mjs" } })}\n`);
package/build-release.mjs CHANGED
@@ -42,12 +42,15 @@ for (let i = 0; i < args.length; i += 1) {
42
42
  }
43
43
  if (platform !== "win" && platform !== "mac") fail("--platform must be win or mac");
44
44
 
45
- const invocationRoot = path.dirname(path.resolve(configName));
45
+ const configPath = path.resolve(configName);
46
+ const invocationRoot = path.dirname(configPath);
46
47
  const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
47
48
  assertPrimaryReleaseCheckout(repoRoot);
48
- const relativeConfig = path.relative(repoRoot, path.resolve(configName)).replaceAll("\\", "/");
49
+ const relativeConfig = path.relative(repoRoot, configPath).replaceAll("\\", "/");
49
50
  if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
50
- const vaultRoot = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) }).vaultRoot;
51
+ const appRoot = path.dirname(configPath);
52
+ const layout = resolveReleaseLayout({ repoRoot, configPath });
53
+ const vaultRoot = layout.vaultRoot;
51
54
  mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
52
55
  const lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
53
56
  let child = null;
@@ -65,31 +68,20 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
65
68
 
66
69
  try {
67
70
  throwIfInterrupted();
68
- if (!dryRun) git(repoRoot, ["fetch", "origin", "main"]);
69
- const commit = git(repoRoot, ["rev-parse", dryRun ? "HEAD" : "origin/main"]);
71
+ const commit = git(repoRoot, ["rev-parse", "HEAD"]);
70
72
  const shortCommit = commit.slice(0, 8);
71
- const worktree = path.join(vaultRoot, "worktrees", `${platform}-${shortCommit}`);
72
- if (!existsSync(worktree)) {
73
- if (dryRun) fail(`dry-run needs an existing worktree: ${worktree}`);
74
- mkdirSync(path.dirname(worktree), { recursive: true });
75
- runChecked("git", ["worktree", "add", "--detach", worktree, commit], repoRoot);
76
- } else if (git(worktree, ["rev-parse", "HEAD"]) !== commit) {
77
- fail(`release worktree exists at the wrong commit: ${worktree}`);
78
- }
79
-
80
- const worktreeConfigPath = path.join(worktree, relativeConfig);
81
- const appRoot = path.dirname(worktreeConfigPath);
82
- const layout = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) });
83
- const config = (await import(`${pathToFileURL(worktreeConfigPath).href}?commit=${commit}`)).default;
73
+ const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
74
+ if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
75
+ const config = (await import(`${pathToFileURL(configPath).href}?commit=${commit}`)).default;
84
76
  if (!config?.app || !config?.version) fail("release config must expose app and version");
85
77
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(config.app) || !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(config.version)) {
86
78
  fail("app and version must be safe release-id components");
87
79
  }
88
80
  const target = config.targets?.[platform];
89
81
  if (!target?.package) fail(`${config.app} has no ${platform} package command`);
90
- const dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, invocationRoot, relativeConfig, target.buildInputs ?? config.buildInputs), commit);
82
+ const requiredInputs = inputPaths(appRoot, configPath, target);
83
+ const dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, appRoot, target.buildInputs ?? config.buildInputs, requiredInputs), commit);
91
84
  if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
92
- const requiredInputs = inputPaths(appRoot, worktreeConfigPath, target);
93
85
  const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
94
86
  const inputHashes = hashInputs(requiredInputs);
95
87
  const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
@@ -141,6 +133,8 @@ try {
141
133
  commit,
142
134
  platform,
143
135
  requiredInputs,
136
+ inputHashes,
137
+ cacheKey,
144
138
  ops: {
145
139
  preflight: async () => {
146
140
  throwIfInterrupted();
@@ -168,7 +162,7 @@ try {
168
162
  mkdirSync(cacheTarget, { recursive: true });
169
163
  if (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
170
164
  else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
171
- fail(`worktree target exists and is not the release cache link: ${targetLink}`);
165
+ fail(`primary checkout target exists and is not the release cache link: ${targetLink}`);
172
166
  }
173
167
  await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget);
174
168
  },
@@ -176,7 +170,7 @@ try {
176
170
  throwIfInterrupted();
177
171
  await runProgress(
178
172
  process.execPath,
179
- [WORKER, "--config", worktreeConfigPath, "--platform", platform, "--no-upload"],
173
+ [WORKER, "--config", configPath, "--platform", platform, "--no-upload"],
180
174
  appRoot,
181
175
  env,
182
176
  [env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
@@ -186,7 +180,7 @@ try {
186
180
  checkpoint(stateRoot, "hardened");
187
181
  },
188
182
  seal: async ({ sealedDir }) => {
189
- sealRelease({ configRoot: path.dirname(worktreeConfigPath), sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey });
183
+ sealRelease({ configRoot: appRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey });
190
184
  verifySealedRelease(sealedDir);
191
185
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
192
186
  checkpoint(stateRoot, "sealed");
@@ -220,20 +214,19 @@ function inputPaths(appRoot, configPath, target) {
220
214
  return required;
221
215
  }
222
216
 
223
- function repoBuildInputs(repoRoot, appRoot, relativeConfig, configured) {
217
+ function repoBuildInputs(repoRoot, appRoot, configured, requiredInputs) {
224
218
  if (!Array.isArray(configured?.include) || configured.include.length === 0) {
225
219
  fail("release config must declare non-empty buildInputs.include paths");
226
220
  }
227
221
  const appPrefix = path.relative(repoRoot, appRoot).replaceAll("\\", "/");
228
- const qualify = (pattern) => [appPrefix, pattern.replaceAll("\\", "/").replace(/^\.\//, "")].filter(Boolean).join("/");
222
+ const qualify = (pattern) => path.posix.normalize([appPrefix, pattern.replaceAll("\\", "/").replace(/^\.\//, "")].filter(Boolean).join("/"));
223
+ const required = requiredInputs
224
+ .map((file) => path.relative(repoRoot, file).replaceAll("\\", "/"))
225
+ .filter((file) => file && file !== ".." && !file.startsWith("../") && !path.isAbsolute(file));
229
226
  return {
230
- include: [
231
- relativeConfig,
232
- qualify("package.json"),
233
- qualify("pnpm-lock.yaml"),
234
- ...configured.include.map(qualify),
235
- ],
227
+ include: configured.include.map(qualify),
236
228
  exclude: (configured.exclude ?? []).map(qualify),
229
+ required,
237
230
  json: Object.fromEntries(Object.entries(configured.json ?? {}).map(([file, fields]) => [qualify(file), fields])),
238
231
  };
239
232
  }
@@ -22,3 +22,23 @@ test("macOS builds default to Cache V2 while explicit legacy mode remains availa
22
22
  assert.match(source, /assertPrimaryReleaseCheckout\(repoRoot\)/);
23
23
  assert.match(source, /dirtyBuildInputs\(repoRoot/);
24
24
  });
25
+
26
+ test("production builds package from the real primary app checkout", () => {
27
+ assert.match(source, /const configPath = path\.resolve\(configName\)/);
28
+ assert.match(source, /const appRoot = path\.dirname\(configPath\)/);
29
+ assert.match(source, /\[WORKER, "--config", configPath, "--platform", platform, "--no-upload"\]/);
30
+ assert.match(source, /sealRelease\(\{ configRoot: appRoot,/);
31
+ });
32
+
33
+ test("production builds never create or use an internal Git worktree", () => {
34
+ assert.doesNotMatch(source, /git", \["worktree", "add"/);
35
+ assert.doesNotMatch(source, /path\.join\(vaultRoot, "worktrees"/);
36
+ assert.doesNotMatch(source, /worktreeConfigPath/);
37
+ });
38
+
39
+ test("dirty release config is rejected before the config module is imported", () => {
40
+ const guard = source.indexOf("dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit)");
41
+ const imported = source.indexOf("await import(");
42
+ assert.ok(guard >= 0, "config cleanliness guard must exist");
43
+ assert.ok(guard < imported, "config cleanliness guard must run before module import");
44
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.39",
3
+ "version": "0.2.40",
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": {
@@ -21,6 +21,8 @@ export function assertPrimaryReleaseCheckout(repoRoot) {
21
21
  if (canonical(repoRoot) !== canonical(primary)) {
22
22
  throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
23
23
  }
24
+ const branch = spawnSync("git", ["symbolic-ref", "--quiet", "HEAD"], { cwd: repoRoot, encoding: "utf8", windowsHide: true });
25
+ if (branch.status !== 0) throw new Error("right-release requires a branch-attached primary Git checkout; detached HEAD is forbidden");
24
26
  }
25
27
 
26
28
  function pathspec(pattern, exclude = false) {
@@ -55,9 +57,25 @@ export function dirtyBuildInputs(repoRoot, inputs, baseline = "HEAD") {
55
57
  if (!Array.isArray(inputs?.include) || inputs.include.length === 0) {
56
58
  throw new Error("release config must declare non-empty buildInputs.include paths");
57
59
  }
60
+ const required = new Set(inputs.required ?? []);
61
+ const files = [
62
+ ...statusFiles(repoRoot, inputs.include, inputs.exclude),
63
+ ...statusFiles(repoRoot, [...required], []),
64
+ ];
65
+ return [...new Set(files)]
66
+ .filter((file) => required.has(file)
67
+ ? fileChangedFromBaseline(repoRoot, file, baseline)
68
+ : inputs.json?.[file]
69
+ ? jsonProjectionChanged(repoRoot, file, inputs.json[file], baseline)
70
+ : fileChangedFromBaseline(repoRoot, file, baseline))
71
+ .sort();
72
+ }
73
+
74
+ function statusFiles(repoRoot, include, exclude = []) {
75
+ if (!include.length) return [];
58
76
  const specs = [
59
- ...inputs.include.map((pattern) => pathspec(pattern)),
60
- ...(inputs.exclude ?? []).map((pattern) => pathspec(pattern, true)),
77
+ ...include.map((pattern) => pathspec(pattern)),
78
+ ...exclude.map((pattern) => pathspec(pattern, true)),
61
79
  ];
62
80
  const output = git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...specs]);
63
81
  const records = output.split("\0");
@@ -69,9 +87,5 @@ export function dirtyBuildInputs(repoRoot, inputs, baseline = "HEAD") {
69
87
  files.push(record.slice(3).replaceAll("\\", "/"));
70
88
  if (/[RC]/.test(status)) index += 1;
71
89
  }
72
- return [...new Set(files)]
73
- .filter((file) => inputs.json?.[file]
74
- ? jsonProjectionChanged(repoRoot, file, inputs.json[file], baseline)
75
- : fileChangedFromBaseline(repoRoot, file, baseline))
76
- .sort();
90
+ return files;
77
91
  }
package/release-state.mjs CHANGED
@@ -111,6 +111,8 @@ export async function runBuildStateMachine({
111
111
  commit,
112
112
  platform,
113
113
  requiredInputs = [],
114
+ inputHashes,
115
+ cacheKey,
114
116
  ops,
115
117
  }) {
116
118
  const releaseId = `${app}-${version}-${commit.slice(0, 8)}`;
@@ -121,6 +123,11 @@ export async function runBuildStateMachine({
121
123
  if (sealed.manifest.app !== app || sealed.manifest.version !== version || sealed.manifest.commit !== commit) {
122
124
  throw new Error(`sealed release identity mismatch: ${releaseId}`);
123
125
  }
126
+ if (sealed.manifest.platform !== platform) throw new Error(`sealed release platform identity mismatch: ${releaseId}`);
127
+ if (inputHashes && JSON.stringify(sealed.manifest.inputs) !== JSON.stringify(inputHashes)) {
128
+ throw new Error(`sealed release input identity mismatch: ${releaseId}`);
129
+ }
130
+ if (cacheKey && sealed.manifest.cacheKey !== cacheKey) throw new Error(`sealed release cache identity mismatch: ${releaseId}`);
124
131
  return { status: "sealed", resumed: true, releaseId, sealedDir };
125
132
  }
126
133
  await ops.preflight?.({ root, app, version, commit, platform, requiredInputs, releaseId });
@@ -198,6 +198,40 @@ test("a valid sealed release resumes without rebuilding", async () => {
198
198
  assert.equal(builds, 0);
199
199
  });
200
200
 
201
+ test("sealed build resume rejects changed input or cache identity", async () => {
202
+ const fx = fixture();
203
+ fx.manifest.inputs = { "src-tauri/Cargo.lock": "old-hash" };
204
+ fx.manifest.cacheKey = "old-cache";
205
+ writeFileSync(path.join(fx.sealedDir, "release-manifest.json"), `${JSON.stringify(fx.manifest, null, 2)}\n`);
206
+
207
+ await assert.rejects(
208
+ runBuildStateMachine({
209
+ root: fx.root,
210
+ app: fx.manifest.app,
211
+ version: fx.manifest.version,
212
+ commit: fx.manifest.commit,
213
+ platform: fx.manifest.platform,
214
+ inputHashes: { "src-tauri/Cargo.lock": "new-hash" },
215
+ cacheKey: fx.manifest.cacheKey,
216
+ ops: {},
217
+ }),
218
+ /sealed release input identity mismatch/i,
219
+ );
220
+ await assert.rejects(
221
+ runBuildStateMachine({
222
+ root: fx.root,
223
+ app: fx.manifest.app,
224
+ version: fx.manifest.version,
225
+ commit: fx.manifest.commit,
226
+ platform: fx.manifest.platform,
227
+ inputHashes: fx.manifest.inputs,
228
+ cacheKey: "new-cache",
229
+ ops: {},
230
+ }),
231
+ /sealed release cache identity mismatch/i,
232
+ );
233
+ });
234
+
201
235
  test("manifest or file tampering blocks upload before any mutation", async () => {
202
236
  const fx = fixture();
203
237
  writeFileSync(fx.installer, "changed");
@@ -32,6 +32,33 @@ const apps = [
32
32
  { key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs"] },
33
33
  ];
34
34
 
35
+ function assertReleasePackageScripts(scripts, label) {
36
+ for (const platform of ["mac", "win"]) {
37
+ assert.equal(scripts[`release:build:${platform}`], `right-release build --platform ${platform}`, `${label} must use the tier-neutral ${platform} build entry point`);
38
+ for (const tier of ["patch", "update"]) {
39
+ assert.equal(scripts[`release:upload:${tier}:${platform}`], `right-release upload --platform ${platform} --tier ${tier}`, `${label} must select ${tier} only during ${platform} upload`);
40
+ }
41
+ }
42
+
43
+ const genericTauri = scripts.tauri;
44
+ if (typeof genericTauri === "string") {
45
+ assert.doesNotMatch(
46
+ genericTauri.trim(),
47
+ /^(?:(?:pnpm|npm)\s+(?:(?:exec|dlx)\s+)?|npx\s+)?(?:(?:\.\/)?node_modules\/\.bin\/)?(?:@tauri-apps\/cli\s+)?tauri(?:\s|$)/,
48
+ `${label} generic \"tauri\" package script bypasses the RightKit release entry points; use an explicitly named dev, debug, unsigned, or local command instead`,
49
+ );
50
+ }
51
+ }
52
+
53
+ function assertMacPackageEntry(packageCommand, scripts, label) {
54
+ assert.equal(packageCommand.cmd, "pnpm", `${label} must use the shared pnpm package entry point`);
55
+ assert.deepEqual(packageCommand.args, ["run", "rightkit:package:mac"], `${label} R2 release must use the dedicated signed and notarized macOS package entry point`);
56
+ const implementation = scripts["rightkit:package:mac"];
57
+ assert.equal(typeof implementation, "string", `${label} must define rightkit:package:mac`);
58
+ assert.doesNotMatch(implementation, /\bright-release\b/, `${label} rightkit:package:mac must package directly instead of recursing into the release build`);
59
+ assert.doesNotMatch(implementation, /\bpnpm(?:\s+run)?\s+rightkit:package:mac\b/, `${label} rightkit:package:mac must not recurse into itself`);
60
+ }
61
+
35
62
  function resolveCargoVersionContract(versionManifest, canonicalVersions) {
36
63
  const consumer = new Map(Object.entries(versionManifest.cargo ?? {}));
37
64
  const staged = new Map(Object.entries(versionManifest.stagedCargo ?? {}));
@@ -314,10 +341,58 @@ test("Cargo version contract rejects staged versions that mismatch canonical man
314
341
  );
315
342
  });
316
343
 
344
+ test("release package scripts use tier-neutral builds and tiered uploads", () => {
345
+ const scripts = {
346
+ "release:build:mac": "right-release build --platform mac",
347
+ "release:build:win": "right-release build --platform win",
348
+ "release:upload:patch:mac": "right-release upload --platform mac --tier patch",
349
+ "release:upload:patch:win": "right-release upload --platform win --tier patch",
350
+ "release:upload:update:mac": "right-release upload --platform mac --tier update",
351
+ "release:upload:update:win": "right-release upload --platform win --tier update",
352
+ };
353
+ assert.doesNotThrow(() => assertReleasePackageScripts(scripts, "fixture"));
354
+ });
355
+
356
+ test("release package scripts reject a generic direct Tauri alias but allow named local commands", () => {
357
+ const scripts = {
358
+ "release:build:mac": "right-release build --platform mac",
359
+ "release:build:win": "right-release build --platform win",
360
+ "release:upload:patch:mac": "right-release upload --platform mac --tier patch",
361
+ "release:upload:patch:win": "right-release upload --platform win --tier patch",
362
+ "release:upload:update:mac": "right-release upload --platform mac --tier update",
363
+ "release:upload:update:win": "right-release upload --platform win --tier update",
364
+ "tauri:dev": "tauri dev",
365
+ "mac:build:debug": "tauri build --debug",
366
+ "build:win:unsigned": "tauri build --bundles nsis --no-sign",
367
+ "cargo:check": "cargo check --locked",
368
+ };
369
+ assert.doesNotThrow(() => assertReleasePackageScripts(scripts, "fixture"));
370
+ for (const genericTauri of ["tauri", "pnpm exec tauri", "pnpm dlx tauri", "./node_modules/.bin/tauri"]) {
371
+ assert.throws(
372
+ () => assertReleasePackageScripts({ ...scripts, tauri: genericTauri }, "fixture"),
373
+ /generic \"tauri\" package script bypasses/i,
374
+ genericTauri,
375
+ );
376
+ }
377
+ });
378
+
379
+ test("mac release config uses a dedicated non-recursive package entry point", () => {
380
+ const command = { cmd: "pnpm", args: ["run", "rightkit:package:mac"] };
381
+ assert.doesNotThrow(() => assertMacPackageEntry(command, { "rightkit:package:mac": "node scripts/mac-dmg.mjs --notarize" }, "fixture"));
382
+ assert.throws(
383
+ () => assertMacPackageEntry(command, { "rightkit:package:mac": "right-release build --platform mac" }, "fixture"),
384
+ /instead of recursing into the release build/i,
385
+ );
386
+ assert.throws(
387
+ () => assertMacPackageEntry(command, { "rightkit:package:mac": "pnpm run rightkit:package:mac" }, "fixture"),
388
+ /must not recurse into itself/i,
389
+ );
390
+ });
391
+
317
392
  test("RightKit exposes one current version manifest", () => {
318
393
  assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
319
394
  assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
320
- assert.equal(versions.npm["@rightkit/release"], "0.2.39");
395
+ assert.equal(versions.npm["@rightkit/release"], "0.2.40");
321
396
  assert.equal(versions.npm["@rightkit/legal"], "0.2.0");
322
397
  assert.equal(versions.npm["@rightkit/license"], "0.1.5");
323
398
  assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
@@ -399,14 +474,7 @@ for (const app of apps) {
399
474
  assert.equal(pkg.scripts["release:doctor"], "right-release doctor");
400
475
  assert.equal(pkg.scripts["release:mac"], undefined, "tierless release entry points are forbidden");
401
476
  assert.equal(pkg.scripts["release:win"], undefined, "tierless release entry points are forbidden");
402
- assert.equal(pkg.scripts["release:patch:mac"], "right-release --platform mac --tier patch");
403
- assert.equal(pkg.scripts["release:patch:win"], "right-release --platform win --tier patch");
404
- assert.equal(pkg.scripts["release:update:mac"], "right-release --platform mac --tier update");
405
- assert.equal(pkg.scripts["release:update:win"], "right-release --platform win --tier update");
406
- assert.equal(pkg.scripts["publish:patch:mac"], "right-release publish --platform mac --tier patch");
407
- assert.equal(pkg.scripts["publish:patch:win"], "right-release publish --platform win --tier patch");
408
- assert.equal(pkg.scripts["publish:update:mac"], "right-release publish --platform mac --tier update");
409
- assert.equal(pkg.scripts["publish:update:win"], "right-release publish --platform win --tier update");
477
+ assertReleasePackageScripts(pkg.scripts, app.key);
410
478
  assert.equal(pkg.scripts["deps:check"], "right-release deps --check");
411
479
  assert.equal(pkg.scripts["deps:update"], "right-release deps --update");
412
480
  assert.ok(!Object.entries(pkg.scripts).some(([name, command]) => /^(?:release|publish):/i.test(name) && /unsigned|--no-sign/i.test(command)), `${app.key} release/publish commands must not expose an unsigned macOS DMG mode`);
@@ -431,8 +499,7 @@ for (const app of apps) {
431
499
  assert.match(updater.key, /\/updates\/(mac|windows)\/current\//, `${app.key} ${platform} updaters must replace the stable current R2 object`);
432
500
  }
433
501
  }
434
- assert.equal(config.targets.mac.package.cmd, "pnpm", `${app.key} must use the shared pnpm package entry point`);
435
- assert.deepEqual(config.targets.mac.package.args, ["run", "mac:dmg:notarized"], `${app.key} R2 release must use the signed and notarized macOS package entry point`);
502
+ assertMacPackageEntry(config.targets.mac.package, pkg.scripts, app.key);
436
503
  for (const installer of config.targets.mac.installer.artifacts) {
437
504
  assert.equal(path.dirname(installer.file), ".", `${app.key} macOS installer must be copied to the app package root before upload`);
438
505
  }
@@ -7,7 +7,7 @@
7
7
  "@rightkit/logs": "0.1.3",
8
8
  "@rightkit/platform-ui": "0.1.0",
9
9
  "@rightkit/qa": "0.1.0",
10
- "@rightkit/release": "0.2.39",
10
+ "@rightkit/release": "0.2.40",
11
11
  "@rightkit/tauri": "0.1.0",
12
12
  "@rightkit/updates": "0.2.3"
13
13
  },