@rightkit/release 0.2.36 → 0.2.38

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.
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+
8
+ import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
9
+
10
+ function git(cwd, ...args) {
11
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
12
+ }
13
+
14
+ function makeRepo() {
15
+ const root = mkdtempSync(path.join(os.tmpdir(), "right-release-invocation-"));
16
+ git(root, "init", "--initial-branch", "main");
17
+ git(root, "config", "user.email", "release-test@example.com");
18
+ git(root, "config", "user.name", "Release Test");
19
+ mkdirSync(path.join(root, "src"));
20
+ mkdirSync(path.join(root, "tests"));
21
+ mkdirSync(path.join(root, "docs"));
22
+ writeFileSync(path.join(root, "package.json"), "{}\n");
23
+ writeFileSync(path.join(root, "src", "app.js"), "export const app = true;\n");
24
+ writeFileSync(path.join(root, "tests", "app.test.js"), "// test\n");
25
+ writeFileSync(path.join(root, "docs", "notes.md"), "notes\n");
26
+ git(root, "add", ".");
27
+ git(root, "commit", "-m", "initial");
28
+ return root;
29
+ }
30
+
31
+ const buildInputs = {
32
+ include: ["package.json", "src/**"],
33
+ exclude: ["**/*.test.*", "**/tests/**"],
34
+ json: {
35
+ "package.json": [["dependencies"], ["scripts", "build"]],
36
+ },
37
+ };
38
+
39
+ test("rejects release invocation from a linked Git worktree", () => {
40
+ const primary = makeRepo();
41
+ const linked = path.join(path.dirname(primary), `${path.basename(primary)}-linked`);
42
+ git(primary, "worktree", "add", "--detach", linked);
43
+
44
+ assert.throws(
45
+ () => assertPrimaryReleaseCheckout(linked),
46
+ /must be invoked from the primary Git worktree/i,
47
+ );
48
+ });
49
+
50
+ test("allows release invocation from the primary Git checkout", () => {
51
+ const primary = makeRepo();
52
+
53
+ assert.doesNotThrow(() => assertPrimaryReleaseCheckout(primary));
54
+ });
55
+
56
+ test("reports dirty files that can change the packaged app", () => {
57
+ const primary = makeRepo();
58
+ writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
59
+ writeFileSync(path.join(primary, "src", "new-runtime.js"), "export {};\n");
60
+
61
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs), [
62
+ "src/app.js",
63
+ "src/new-runtime.js",
64
+ ]);
65
+ });
66
+
67
+ test("ignores dirty files outside the packaged app input set", () => {
68
+ const primary = makeRepo();
69
+ writeFileSync(path.join(primary, "docs", "notes.md"), "changed notes\n");
70
+ writeFileSync(path.join(primary, "tests", "app.test.js"), "// changed test\n");
71
+ writeFileSync(path.join(primary, "scratch.txt"), "evaluation output\n");
72
+
73
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs), []);
74
+ });
75
+
76
+ test("checks only build-relevant fields in mixed-purpose JSON manifests", () => {
77
+ const primary = makeRepo();
78
+ writeFileSync(path.join(primary, "package.json"), `${JSON.stringify({ scripts: { bakeoff: "node eval.mjs" } })}\n`);
79
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs), []);
80
+
81
+ writeFileSync(path.join(primary, "package.json"), `${JSON.stringify({ dependencies: { tauri: "2.0.0" } })}\n`);
82
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs), ["package.json"]);
83
+ });
84
+
85
+ test("does not report a locally dirty file whose bytes already equal the release commit", () => {
86
+ const primary = makeRepo();
87
+ const localHead = git(primary, "rev-parse", "HEAD");
88
+ writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
89
+ git(primary, "add", "src/app.js");
90
+ git(primary, "commit", "-m", "release change");
91
+ const releaseCommit = git(primary, "rev-parse", "HEAD");
92
+ git(primary, "checkout", localHead);
93
+ writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
94
+
95
+ assert.deepEqual(dirtyBuildInputs(primary, buildInputs, releaseCommit), []);
96
+ });
package/build-release.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  import path from "node:path";
21
21
  import { spawn, spawnSync } from "node:child_process";
22
22
  import { fileURLToPath, pathToFileURL } from "node:url";
23
+ import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
23
24
  import { cacheFingerprint, commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, watchProgress } from "./release-state.mjs";
24
25
 
25
26
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
@@ -42,6 +43,7 @@ if (platform !== "win" && platform !== "mac") fail("--platform must be win or ma
42
43
 
43
44
  const invocationRoot = path.dirname(path.resolve(configName));
44
45
  const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
46
+ assertPrimaryReleaseCheckout(repoRoot);
45
47
  const relativeConfig = path.relative(repoRoot, path.resolve(configName)).replaceAll("\\", "/");
46
48
  if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
47
49
  const vaultRoot = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) }).vaultRoot;
@@ -80,6 +82,10 @@ try {
80
82
  }
81
83
  const target = config.targets?.[platform];
82
84
  if (!target?.package) fail(`${config.app} has no ${platform} package command`);
85
+ const dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, invocationRoot, relativeConfig, target.buildInputs ?? config.buildInputs), commit);
86
+ if (dirtyInputs.length > 0) {
87
+ fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
88
+ }
83
89
  const requiredInputs = inputPaths(appRoot, worktreeConfigPath, target);
84
90
  const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
85
91
  const inputHashes = hashInputs(requiredInputs);
@@ -182,6 +188,24 @@ function inputPaths(appRoot, configPath, target) {
182
188
  return required;
183
189
  }
184
190
 
191
+ function repoBuildInputs(repoRoot, appRoot, relativeConfig, configured) {
192
+ if (!Array.isArray(configured?.include) || configured.include.length === 0) {
193
+ fail("release config must declare non-empty buildInputs.include paths");
194
+ }
195
+ const appPrefix = path.relative(repoRoot, appRoot).replaceAll("\\", "/");
196
+ const qualify = (pattern) => [appPrefix, pattern.replaceAll("\\", "/").replace(/^\.\//, "")].filter(Boolean).join("/");
197
+ return {
198
+ include: [
199
+ relativeConfig,
200
+ qualify("package.json"),
201
+ qualify("pnpm-lock.yaml"),
202
+ ...configured.include.map(qualify),
203
+ ],
204
+ exclude: (configured.exclude ?? []).map(qualify),
205
+ json: Object.fromEntries(Object.entries(configured.json ?? {}).map(([file, fields]) => [qualify(file), fields])),
206
+ };
207
+ }
208
+
185
209
  function hashInputs(files) {
186
210
  return Object.fromEntries(files.map((file) => [path.relative(path.dirname(path.dirname(file)), file), hashFile(file)]));
187
211
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
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": {
package/pub-upload.mjs CHANGED
@@ -8,12 +8,7 @@ const BUCKETS = new Map([
8
8
  ["public", "rightapps-downloads"],
9
9
  ["private", "rightapps-updates"],
10
10
  ]);
11
- // Wrangler always runs remotely through a package runner; which one exists is machine-specific
12
- // (a node install without npm/npx is normal when pnpm is the pinned package manager).
13
- const RUNNERS = [
14
- { cmd: "npx", prefix: [] },
15
- { cmd: "pnpm", prefix: ["dlx"] },
16
- ];
11
+ const RUNNER = { cmd: "pnpm", prefix: ["dlx"] };
17
12
 
18
13
  const [, , localFile, r2Key, bucketAlias = "public"] = process.argv;
19
14
  if (!localFile || !r2Key) {
@@ -48,8 +43,7 @@ const runner = resolveRunner(env);
48
43
  if (!runner) {
49
44
  console.error(
50
45
  "upload-large: no package runner found. Tried: " +
51
- `${RUNNERS.map((entry) => [entry.cmd, ...entry.prefix].join(" ")).join(", ")}. ` +
52
- "Install one, or set RIGHT_RELEASE_WRANGLER_RUNNER (e.g. \"pnpm dlx\").",
46
+ "pnpm dlx. Install the pinned workspace package manager before release.",
53
47
  );
54
48
  process.exit(1);
55
49
  }
@@ -73,21 +67,13 @@ if (result.error) {
73
67
  process.exit(result.status ?? 1);
74
68
 
75
69
  function resolveRunner(env) {
76
- const override = process.env.RIGHT_RELEASE_WRANGLER_RUNNER?.trim();
77
- if (override) {
78
- const [cmd, ...prefix] = override.split(/\s+/);
79
- return { cmd, prefix };
80
- }
81
- for (const candidate of RUNNERS) {
82
- const probe = spawnSync(candidate.cmd, ["--version"], {
83
- env,
84
- stdio: "ignore",
85
- shell: process.platform === "win32",
86
- windowsHide: true,
87
- });
88
- if (!probe.error && probe.status === 0) return candidate;
89
- }
90
- return null;
70
+ const probe = spawnSync(RUNNER.cmd, ["--version"], {
71
+ env,
72
+ stdio: "ignore",
73
+ shell: process.platform === "win32",
74
+ windowsHide: true,
75
+ });
76
+ return !probe.error && probe.status === 0 ? RUNNER : null;
91
77
  }
92
78
 
93
79
  function cleanCloudflareEnv(source) {
@@ -34,6 +34,18 @@ test("build is tier-neutral and rejects a tier before touching a repository", ()
34
34
  assert.match(result.stderr, /build is tier-neutral/i);
35
35
  });
36
36
 
37
+ test("build enforces primary-checkout and build-input cleanliness guards", () => {
38
+ const source = readFileSync(build, "utf8");
39
+ assert.match(source, /assertPrimaryReleaseCheckout\(repoRoot\)/);
40
+ assert.match(source, /dirtyBuildInputs\(repoRoot,/);
41
+ assert.match(source, /dirty files can change the packaged app/i);
42
+ });
43
+
44
+ test("upload enforces the same primary-checkout vault", () => {
45
+ const source = readFileSync(upload, "utf8");
46
+ assert.match(source, /assertPrimaryReleaseCheckout\(repoRoot\)/);
47
+ });
48
+
37
49
  test("upload requires an explicit patch or update tier before reading a release", () => {
38
50
  const result = spawnSync(process.execPath, [upload, "--platform", "win", "--release", "fixture-1.0.0-deadbeef"], {
39
51
  cwd: mkdtempSync(path.join(os.tmpdir(), "right-upload-cli-")),
@@ -52,3 +64,9 @@ test("Windows upload trust verification uses signtool instead of PowerShell modu
52
64
  assert.match(signingSource, /verifyOnly/);
53
65
  assert.match(signingSource, /\["verify", "\/pa", "\/v", file\]/);
54
66
  });
67
+
68
+ test("upload uses only the pinned pnpm runner", () => {
69
+ const source = readFileSync(upload, "utf8");
70
+ assert.match(source, /spawnSync\("pnpm", \["dlx", "wrangler@4"/);
71
+ assert.doesNotMatch(source, /\bnpx\b/);
72
+ });
@@ -0,0 +1,77 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ function git(cwd, args) {
6
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
7
+ if (result.status !== 0) {
8
+ throw new Error(`git ${args.join(" ")} failed: ${(result.stderr || result.stdout).trim()}`);
9
+ }
10
+ return result.stdout;
11
+ }
12
+
13
+ function canonical(file) {
14
+ return path.normalize(realpathSync(file)).toLowerCase();
15
+ }
16
+
17
+ export function assertPrimaryReleaseCheckout(repoRoot) {
18
+ const worktrees = git(repoRoot, ["worktree", "list", "--porcelain"]);
19
+ const primary = worktrees.match(/^worktree (.+)$/m)?.[1];
20
+ if (!primary) throw new Error("unable to resolve the primary Git worktree");
21
+ if (canonical(repoRoot) !== canonical(primary)) {
22
+ throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
23
+ }
24
+ }
25
+
26
+ function pathspec(pattern, exclude = false) {
27
+ const normalized = pattern.replaceAll("\\", "/").replace(/^\.\//, "");
28
+ return `:(top,${exclude ? "exclude," : ""}glob)${normalized}`;
29
+ }
30
+
31
+ function jsonProjectionChanged(repoRoot, file, fields, baseline) {
32
+ try {
33
+ const working = JSON.parse(readFileSync(path.join(repoRoot, file), "utf8"));
34
+ const committed = JSON.parse(git(repoRoot, ["show", `${baseline}:${file}`]));
35
+ const project = (value) => fields.map((segments) => segments.reduce((current, segment) => current?.[segment], value));
36
+ return JSON.stringify(project(working)) !== JSON.stringify(project(committed));
37
+ } catch {
38
+ return true;
39
+ }
40
+ }
41
+
42
+ function fileChangedFromBaseline(repoRoot, file, baseline) {
43
+ const workingPath = path.join(repoRoot, file);
44
+ if (!existsSync(workingPath)) return true;
45
+ try {
46
+ const workingHash = git(repoRoot, ["hash-object", "--", file]).trim();
47
+ const baselineHash = git(repoRoot, ["rev-parse", `${baseline}:${file}`]).trim();
48
+ return workingHash !== baselineHash;
49
+ } catch {
50
+ return true;
51
+ }
52
+ }
53
+
54
+ export function dirtyBuildInputs(repoRoot, inputs, baseline = "HEAD") {
55
+ if (!Array.isArray(inputs?.include) || inputs.include.length === 0) {
56
+ throw new Error("release config must declare non-empty buildInputs.include paths");
57
+ }
58
+ const specs = [
59
+ ...inputs.include.map((pattern) => pathspec(pattern)),
60
+ ...(inputs.exclude ?? []).map((pattern) => pathspec(pattern, true)),
61
+ ];
62
+ const output = git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...specs]);
63
+ const records = output.split("\0");
64
+ const files = [];
65
+ for (let index = 0; index < records.length; index += 1) {
66
+ const record = records[index];
67
+ if (!record) continue;
68
+ const status = record.slice(0, 2);
69
+ files.push(record.slice(3).replaceAll("\\", "/"));
70
+ if (/[RC]/.test(status)) index += 1;
71
+ }
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();
77
+ }
@@ -317,7 +317,7 @@ test("Cargo version contract rejects staged versions that mismatch canonical man
317
317
  test("RightKit exposes one current version manifest", () => {
318
318
  assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
319
319
  assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
320
- assert.equal(versions.npm["@rightkit/release"], "0.2.36");
320
+ assert.equal(versions.npm["@rightkit/release"], "0.2.38");
321
321
  assert.equal(versions.npm["@rightkit/legal"], "0.2.0");
322
322
  assert.equal(versions.npm["@rightkit/license"], "0.1.5");
323
323
  assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
@@ -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.36",
10
+ "@rightkit/release": "0.2.38",
11
11
  "@rightkit/tauri": "0.1.0",
12
12
  "@rightkit/updates": "0.2.3"
13
13
  },
package/upload-large.mjs CHANGED
@@ -8,12 +8,7 @@ const BUCKETS = new Map([
8
8
  ["public", "rightapps-downloads"],
9
9
  ["private", "rightapps-updates"],
10
10
  ]);
11
- // Wrangler always runs remotely through a package runner; which one exists is machine-specific
12
- // (a node install without npm/npx is normal when pnpm is the pinned package manager).
13
- const RUNNERS = [
14
- { cmd: "npx", prefix: [] },
15
- { cmd: "pnpm", prefix: ["dlx"] },
16
- ];
11
+ const RUNNER = { cmd: "pnpm", prefix: ["dlx"] };
17
12
 
18
13
  const [, , localFile, r2Key, bucketAlias = "public"] = process.argv;
19
14
  if (!localFile || !r2Key) {
@@ -48,8 +43,7 @@ const runner = resolveRunner(env);
48
43
  if (!runner) {
49
44
  console.error(
50
45
  "upload-large: no package runner found. Tried: " +
51
- `${RUNNERS.map((entry) => [entry.cmd, ...entry.prefix].join(" ")).join(", ")}. ` +
52
- "Install one, or set RIGHT_RELEASE_WRANGLER_RUNNER (e.g. \"pnpm dlx\").",
46
+ "pnpm dlx. Install the pinned workspace package manager before release.",
53
47
  );
54
48
  process.exit(1);
55
49
  }
@@ -73,21 +67,13 @@ if (result.error) {
73
67
  process.exit(result.status ?? 1);
74
68
 
75
69
  function resolveRunner(env) {
76
- const override = process.env.RIGHT_RELEASE_WRANGLER_RUNNER?.trim();
77
- if (override) {
78
- const [cmd, ...prefix] = override.split(/\s+/);
79
- return { cmd, prefix };
80
- }
81
- for (const candidate of RUNNERS) {
82
- const probe = spawnSync(candidate.cmd, ["--version"], {
83
- env,
84
- stdio: "ignore",
85
- shell: process.platform === "win32",
86
- windowsHide: true,
87
- });
88
- if (!probe.error && probe.status === 0) return candidate;
89
- }
90
- return null;
70
+ const probe = spawnSync(RUNNER.cmd, ["--version"], {
71
+ env,
72
+ stdio: "ignore",
73
+ shell: process.platform === "win32",
74
+ windowsHide: true,
75
+ });
76
+ return !probe.error && probe.status === 0 ? RUNNER : null;
91
77
  }
92
78
 
93
79
  function cleanCloudflareEnv(source) {
@@ -46,10 +46,7 @@ test("refuses unknown bucket aliases", () => {
46
46
  test("resolves a real package runner for wrangler on this machine", () => {
47
47
  const result = run("public");
48
48
  assert.equal(result.status, 0, result.stderr);
49
- // A node install carrying neither npm nor npx is normal where pnpm is the pinned package
50
- // manager; the uploader must find a runner that actually exists rather than assume npx.
51
- // Assuming npx once made every R2 upload a silent no-op while the publish reported success.
52
- assert.match(result.stdout, /^runner: (npx|pnpm dlx)$/m);
49
+ assert.match(result.stdout, /^runner: pnpm dlx$/m);
53
50
  });
54
51
 
55
52
  test("fails loudly when no package runner exists instead of skipping the upload", () => {
@@ -71,16 +68,3 @@ test("fails loudly when no package runner exists instead of skipping the upload"
71
68
  assert.notEqual(result.status, 0, "a box with no runner must not report a successful upload");
72
69
  assert.match(result.stderr, /no package runner found/);
73
70
  });
74
-
75
- test("honours an explicit runner override", () => {
76
- const result = spawnSync(
77
- process.execPath,
78
- [uploader, fixtureFile(), "fixture/installers/windows/current/Artifact.exe", "public"],
79
- {
80
- encoding: "utf8",
81
- env: { ...process.env, RIGHT_RELEASE_UPLOAD_DRY_RUN: "1", RIGHT_RELEASE_WRANGLER_RUNNER: "pnpm dlx" },
82
- },
83
- );
84
- assert.equal(result.status, 0, result.stderr);
85
- assert.match(result.stdout, /^runner: pnpm dlx$/m);
86
- });
@@ -4,6 +4,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, wr
4
4
  import path from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
7
8
  import { runUploadStateMachine, verifySealedRelease } from "./release-state.mjs";
8
9
 
9
10
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
@@ -34,6 +35,7 @@ if (!releaseId) fail("--release is required");
34
35
  if (platform !== "win" && platform !== "mac") fail("--platform must be win or mac");
35
36
 
36
37
  const repoRoot = git(process.cwd(), ["rev-parse", "--show-toplevel"]);
38
+ assertPrimaryReleaseCheckout(repoRoot);
37
39
  const platformDir = platform === "win" ? "windows" : "mac";
38
40
  const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir);
39
41
  const sealed = verifySealedRelease(sealedDir);
@@ -182,7 +184,7 @@ function bucketName(bucket) {
182
184
  }
183
185
 
184
186
  function wrangler(runArgs) {
185
- return spawnSync("npx", ["wrangler@4", ...runArgs], { cwd: repoRoot, env: process.env, encoding: "utf8", windowsHide: true, shell: process.platform === "win32" });
187
+ return spawnSync("pnpm", ["dlx", "wrangler@4", ...runArgs], { cwd: repoRoot, env: process.env, encoding: "utf8", windowsHide: true, shell: process.platform === "win32" });
186
188
  }
187
189
 
188
190
  function hashFile(file) {