@rightkit/release 0.2.45 → 0.2.47

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/build-release.mjs CHANGED
@@ -25,6 +25,31 @@ import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBui
25
25
  import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
26
26
  import { createTargetBridge } from "./target-bridge.mjs";
27
27
  import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
28
+ import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
29
+
30
+ /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
31
+ function buildPreflight({ config, appRoot, repoRoot, platform }) {
32
+ let version;
33
+ try {
34
+ version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
35
+ } catch {
36
+ version = undefined;
37
+ }
38
+ return collectPreflight({
39
+ platform,
40
+ appRoot,
41
+ repoRoot,
42
+ app: config.app,
43
+ // The version check is the doctor's job: by the time `build` runs, seal
44
+ // collision is already enforced downstream, and duplicating it here would
45
+ // reject a legitimate resume of an in-flight build.
46
+ version: undefined,
47
+ cargoLockPaths: [
48
+ path.join(appRoot, "src-tauri", "Cargo.lock"),
49
+ path.join(appRoot, "Cargo.lock"),
50
+ ],
51
+ });
52
+ }
28
53
 
29
54
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
30
55
  const WORKER = path.join(TOOL_ROOT, "release.mjs");
@@ -98,9 +123,28 @@ try {
98
123
  ? resolveCacheLayout({ cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, fingerprint: cacheKey, kind: "release" })
99
124
  : undefined;
100
125
  if (cacheMode === "shared") assertSccacheVersion(toolVersions.sccache);
126
+
127
+ // Preflight BEFORE any compile. These preconditions used to be discovered one
128
+ // per build cycle, minutes deep, with errors that named a symptom instead of a
129
+ // cause. Blocking problems stop here, in seconds, naming the fix.
130
+ const preflight = buildPreflight({ config, appRoot, repoRoot: layout.repoRoot, platform });
131
+ const preflightBlockers = preflightFailures(preflight);
132
+ if (preflightBlockers.length > 0) {
133
+ fail(`preflight found ${preflightBlockers.length} blocking problem(s):\n${formatPreflight(preflightBlockers)}`);
134
+ }
135
+ for (const check of preflight) {
136
+ if (check.status !== "ok") console.error(`[right-release] preflight ${check.name}: ${check.detail}`);
137
+ }
138
+
139
+ // A tool being INSTALLED is not the same as it being the one PATH picks. The
140
+ // vendored-OpenSSL build shells out to `perl`, and under Git Bash/MSYS that
141
+ // resolves to MSYS Perl, which cannot configure OpenSSL. Put the real Windows
142
+ // Perl first so the build behaves the same from any shell.
143
+ const pathPrefix = buildPathPrefix({ platform });
101
144
  const env = {
102
145
  ...process.env,
103
146
  ...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
147
+ ...(pathPrefix ? { PATH: `${pathPrefix}${path.delimiter}${process.env.PATH ?? ""}` } : {}),
104
148
  RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
105
149
  RIGHT_RELEASE_APP_ROOT: layout.appRoot,
106
150
  };
@@ -129,7 +173,14 @@ try {
129
173
  createdAt: new Date().toISOString(),
130
174
  };
131
175
  const targetLink = path.join(appRoot, "src-tauri", "target");
132
- const targetBridge = createTargetBridge({ link: targetLink, target: env.CARGO_TARGET_DIR });
176
+ // ownedRoot is the per-app cache parent holding every fingerprint dir, so a
177
+ // link left by an earlier fingerprint is recognised as ours and repointed
178
+ // instead of hard-stopping the build.
179
+ const targetBridge = createTargetBridge({
180
+ link: targetLink,
181
+ target: env.CARGO_TARGET_DIR,
182
+ ownedRoot: path.dirname(env.CARGO_TARGET_DIR),
183
+ });
133
184
 
134
185
  const result = await targetBridge.run(async () => runBuildStateMachine({
135
186
  root: repoRoot,
@@ -45,7 +45,11 @@ test("dirty release config is rejected before the config module is imported", ()
45
45
  });
46
46
 
47
47
  test("shared Cache V2 target bridge is owned by a finally-cleaned lifecycle", () => {
48
- assert.match(source, /createTargetBridge\(\{ link: targetLink, target: env\.CARGO_TARGET_DIR \}\)/);
48
+ assert.match(source, /createTargetBridge\(\{\s*link: targetLink,\s*target: env\.CARGO_TARGET_DIR,/);
49
+ // ownedRoot must stay wired: without it the bridge refuses every link left by
50
+ // an earlier fingerprint, so any Cargo.lock or version bump hard-stops the
51
+ // next build until someone deletes the link by hand.
52
+ assert.match(source, /ownedRoot: path\.dirname\(env\.CARGO_TARGET_DIR\)/);
49
53
  assert.match(source, /targetBridge\.run\(.*runBuildStateMachine/s);
50
54
  assert.match(source, /if \(cacheMode === "shared"\) targetBridge\.ensure\(\)/);
51
55
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.45",
3
+ "version": "0.2.47",
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/preflight.mjs ADDED
@@ -0,0 +1,278 @@
1
+ // Release preflight — verify THIS MACHINE can actually complete a build, before
2
+ // the expensive work starts.
3
+ //
4
+ // Why this file exists: every one of these preconditions used to be discovered
5
+ // by failing at it, one per build cycle, deep inside a multi-minute compile,
6
+ // usually with an error that names a symptom rather than the cause. The
7
+ // knowledge then lived in a commit message, a doc, or an agent's memory — which
8
+ // only helps whoever happens to read it. An executable check helps everyone,
9
+ // deterministically, in about a second.
10
+ //
11
+ // Rules for anything added here:
12
+ // - It must be a precondition that can FAIL A BUILD, and it must be checkable
13
+ // cheaply. No network calls, no compiles.
14
+ // - It must name the actual cause and the actual fix, not the symptom.
15
+ // - It must not cry wolf. A check that warns on healthy machines gets ignored,
16
+ // and then so does every other check. (Concrete example of what NOT to add:
17
+ // signing-certificate expiry. Azure Trusted Signing mints short-lived ~3-day
18
+ // certs on purpose and timestamps the signature, so "expires in 2 days" is
19
+ // the healthy state, not a problem.)
20
+
21
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
22
+ import path from "node:path";
23
+ import { spawnSync } from "node:child_process";
24
+
25
+ export const PREFLIGHT_OK = "ok";
26
+ export const PREFLIGHT_WARN = "warn";
27
+ export const PREFLIGHT_FAIL = "fail";
28
+
29
+ function ok(name, detail) {
30
+ return { name, status: PREFLIGHT_OK, detail };
31
+ }
32
+ function warn(name, detail, fix) {
33
+ return { name, status: PREFLIGHT_WARN, detail, fix };
34
+ }
35
+ function fail(name, detail, fix) {
36
+ return { name, status: PREFLIGHT_FAIL, detail, fix };
37
+ }
38
+
39
+ /**
40
+ * Windows SDK tools are versioned directories; pinning one version is why builds
41
+ * broke on machines that had a different SDK installed. Find the newest by
42
+ * NUMERIC segment order — string sort puts 10.0.9 above 10.0.26100.
43
+ */
44
+ export function findWindowsSdkTool(
45
+ tool,
46
+ { sdkRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\bin", arch = "x64", readdir = readdirSync, exists = existsSync } = {},
47
+ ) {
48
+ if (!exists(sdkRoot)) return null;
49
+ let entries;
50
+ try {
51
+ entries = readdir(sdkRoot);
52
+ } catch {
53
+ return null;
54
+ }
55
+ const candidates = entries
56
+ .filter((entry) => /^10\.\d+(\.\d+)*$/.test(entry))
57
+ .map((entry) => ({
58
+ version: entry,
59
+ segments: entry.split(".").map((part) => Number(part) || 0),
60
+ toolPath: path.join(sdkRoot, entry, arch, tool),
61
+ }))
62
+ .filter((entry) => exists(entry.toolPath));
63
+ if (candidates.length === 0) return null;
64
+ candidates.sort((a, b) => {
65
+ const length = Math.max(a.segments.length, b.segments.length);
66
+ for (let index = 0; index < length; index += 1) {
67
+ const diff = (b.segments[index] ?? 0) - (a.segments[index] ?? 0);
68
+ if (diff !== 0) return diff;
69
+ }
70
+ return 0;
71
+ });
72
+ return candidates[0];
73
+ }
74
+
75
+ /**
76
+ * OpenSSL's ./Configure is a Perl script that needs a real Windows Perl. When a
77
+ * build is launched from Git Bash/MSYS, PATH resolves `perl` to MSYS Perl first,
78
+ * which lacks Locale::Maketext::Simple and dies with an error that reads like an
79
+ * OpenSSL problem. Strawberry Perl is usually installed and simply shadowed —
80
+ * nothing is missing, it just is not the one that wins.
81
+ */
82
+ export function resolveWindowsPerl({
83
+ env = process.env,
84
+ exists = existsSync,
85
+ candidates = ["C:\\Strawberry\\perl\\bin\\perl.exe", "C:\\Perl64\\bin\\perl.exe"],
86
+ } = {}) {
87
+ const explicit = [env.OPENSSL_SRC_PERL, env.PERL].filter(Boolean);
88
+ for (const candidate of [...explicit, ...candidates]) {
89
+ if (exists(candidate)) return { perlPath: candidate, dir: path.dirname(candidate) };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** True when the dependency graph vendors OpenSSL, i.e. a Perl build is required. */
95
+ export function graphVendorsOpenssl(lockPath, { read = readFileSync, exists = existsSync } = {}) {
96
+ if (!exists(lockPath)) return false;
97
+ try {
98
+ return /name = "openssl-src"/.test(read(lockPath, "utf8"));
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * right-release refuses to rebuild an already-sealed version, so a stale sealed
106
+ * record turns into a hard stop at build start. Report the next free version
107
+ * instead of just saying "sealed".
108
+ */
109
+ export function nextFreeVersion(sealedDir, app, currentVersion, { readdir = readdirSync, exists = existsSync } = {}) {
110
+ if (!exists(sealedDir)) return { taken: [], collision: false, suggestion: currentVersion };
111
+ let entries = [];
112
+ try {
113
+ entries = readdir(sealedDir);
114
+ } catch {
115
+ return { taken: [], collision: false, suggestion: currentVersion };
116
+ }
117
+ const prefix = `${app}-`;
118
+ const taken = new Set();
119
+ for (const entry of entries) {
120
+ if (!entry.startsWith(prefix)) continue;
121
+ const version = entry.slice(prefix.length).split("-")[0];
122
+ if (/^\d+\.\d+\.\d+$/.test(version)) taken.add(version);
123
+ }
124
+ if (!taken.has(currentVersion)) {
125
+ return { taken: [...taken], collision: false, suggestion: currentVersion };
126
+ }
127
+ const [major, minor] = currentVersion.split(".").map(Number);
128
+ let patch = Number(currentVersion.split(".")[2]);
129
+ let suggestion = currentVersion;
130
+ do {
131
+ patch += 1;
132
+ suggestion = `${major}.${minor}.${patch}`;
133
+ } while (taken.has(suggestion));
134
+ return { taken: [...taken], collision: true, suggestion };
135
+ }
136
+
137
+ function freeDiskGb(targetPath) {
138
+ if (process.platform === "win32") {
139
+ const drive = path.parse(path.resolve(targetPath)).root.replace(/\\$/, "");
140
+ const result = spawnSync(
141
+ "powershell",
142
+ ["-NoProfile", "-Command", `(Get-PSDrive -Name '${drive.replace(":", "")}').Free`],
143
+ { encoding: "utf8", windowsHide: true },
144
+ );
145
+ const bytes = Number(String(result.stdout ?? "").trim());
146
+ return Number.isFinite(bytes) && bytes > 0 ? bytes / 1024 ** 3 : null;
147
+ }
148
+ const result = spawnSync("df", ["-k", targetPath], { encoding: "utf8" });
149
+ const line = String(result.stdout ?? "").trim().split("\n").at(-1) ?? "";
150
+ const available = Number(line.split(/\s+/)[3]);
151
+ return Number.isFinite(available) ? (available * 1024) / 1024 ** 3 : null;
152
+ }
153
+
154
+ function commandVersion(command, args = ["--version"]) {
155
+ const result = spawnSync(command, args, { encoding: "utf8", windowsHide: true, shell: process.platform === "win32" });
156
+ if (result.status !== 0) return null;
157
+ return String(result.stdout ?? result.stderr ?? "").trim().split("\n")[0] || null;
158
+ }
159
+
160
+ /**
161
+ * Run every precondition check. Pure-ish: all filesystem/process access is via
162
+ * the injected helpers so this is testable without a real toolchain.
163
+ */
164
+ export function collectPreflight({
165
+ platform,
166
+ appRoot,
167
+ repoRoot,
168
+ app,
169
+ version,
170
+ cargoLockPaths = [],
171
+ minFreeGb = 25,
172
+ env = process.env,
173
+ } = {}) {
174
+ const checks = [];
175
+ const windows = platform === "win" || platform === "win32";
176
+
177
+ if (version && app && repoRoot) {
178
+ const sealedDir = path.join(repoRoot, ".right-release", "sealed");
179
+ const sealed = nextFreeVersion(sealedDir, app, version);
180
+ checks.push(
181
+ sealed.collision
182
+ ? fail(
183
+ "version",
184
+ `${app} ${version} is already sealed at ${sealedDir}; right-release will refuse to rebuild it`,
185
+ `bump to ${sealed.suggestion} in package.json, src-tauri/tauri.conf.json and src-tauri/Cargo.toml, then re-run the legal notices generator`,
186
+ )
187
+ : ok("version", `${version} is free to build`),
188
+ );
189
+ }
190
+
191
+ if (windows) {
192
+ const makeappx = findWindowsSdkTool("makeappx.exe");
193
+ checks.push(
194
+ makeappx
195
+ ? ok("windows-sdk", `makeappx.exe from SDK ${makeappx.version}`)
196
+ : fail(
197
+ "windows-sdk",
198
+ "no makeappx.exe found under any installed Windows 10/11 SDK",
199
+ "install the Windows SDK, or set MAKEAPPX_PATH to an explicit makeappx.exe",
200
+ ),
201
+ );
202
+
203
+ const signtool = findWindowsSdkTool("signtool.exe");
204
+ checks.push(
205
+ signtool
206
+ ? ok("signtool", `signtool.exe from SDK ${signtool.version}`)
207
+ : warn(
208
+ "signtool",
209
+ "no signtool.exe found under any installed Windows SDK",
210
+ "install the Windows SDK Build Tools, or set AZURE_SIGNTOOL_PATH",
211
+ ),
212
+ );
213
+
214
+ const vendorsOpenssl = cargoLockPaths.some((lockPath) => graphVendorsOpenssl(lockPath));
215
+ if (vendorsOpenssl) {
216
+ const perl = resolveWindowsPerl({ env });
217
+ checks.push(
218
+ perl
219
+ ? ok("perl", `${perl.perlPath} (prepended to PATH for the vendored OpenSSL build)`)
220
+ : fail(
221
+ "perl",
222
+ "the dependency graph vendors OpenSSL, whose ./Configure needs a real Windows Perl, and none was found",
223
+ "install Strawberry Perl (https://strawberryperl.com), or set OPENSSL_SRC_PERL to a Windows perl.exe. MSYS/Git-Bash perl will NOT work: it lacks Locale::Maketext::Simple",
224
+ ),
225
+ );
226
+ }
227
+ }
228
+
229
+ const sccache = commandVersion("sccache");
230
+ checks.push(
231
+ sccache
232
+ ? ok("sccache", sccache)
233
+ : warn("sccache", "sccache not found; the shared cache will compile without a compiler cache", "cargo install sccache --locked"),
234
+ );
235
+
236
+ if (appRoot) {
237
+ const free = freeDiskGb(appRoot);
238
+ if (free === null) {
239
+ checks.push(warn("disk", "could not determine free disk space", null));
240
+ } else {
241
+ checks.push(
242
+ free < minFreeGb
243
+ ? fail(
244
+ "disk",
245
+ `${free.toFixed(1)}GB free on the build drive, below the ${minFreeGb}GB floor`,
246
+ "prune Rust target directories, or run right-release cache prune",
247
+ )
248
+ : ok("disk", `${free.toFixed(1)}GB free`),
249
+ );
250
+ }
251
+ }
252
+
253
+ return checks;
254
+ }
255
+
256
+ export function formatPreflight(checks) {
257
+ const symbol = { [PREFLIGHT_OK]: "ok ", [PREFLIGHT_WARN]: "WARN", [PREFLIGHT_FAIL]: "FAIL" };
258
+ const lines = checks.map((check) => {
259
+ const head = ` [${symbol[check.status]}] ${check.name}: ${check.detail}`;
260
+ return check.fix && check.status !== PREFLIGHT_OK ? `${head}\n fix: ${check.fix}` : head;
261
+ });
262
+ return lines.join("\n");
263
+ }
264
+
265
+ export function preflightFailures(checks) {
266
+ return checks.filter((check) => check.status === PREFLIGHT_FAIL);
267
+ }
268
+
269
+ /**
270
+ * PATH additions the build must inherit. Today this is only Windows Perl, but it
271
+ * is the right home for any "the tool is installed and simply not the one that
272
+ * wins" fix — those are invisible to a plain existence check.
273
+ */
274
+ export function buildPathPrefix({ platform, env = process.env } = {}) {
275
+ if (platform !== "win" && platform !== "win32") return null;
276
+ const perl = resolveWindowsPerl({ env });
277
+ return perl ? perl.dir : null;
278
+ }
@@ -0,0 +1,123 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ buildPathPrefix,
7
+ collectPreflight,
8
+ findWindowsSdkTool,
9
+ formatPreflight,
10
+ graphVendorsOpenssl,
11
+ nextFreeVersion,
12
+ preflightFailures,
13
+ resolveWindowsPerl,
14
+ PREFLIGHT_FAIL,
15
+ } from "./preflight.mjs";
16
+
17
+ test("windows SDK lookup orders versions numerically, not as strings", () => {
18
+ const root = "C:\\Kits";
19
+ const present = new Set([
20
+ root,
21
+ path.join(root, "10.0.9.0", "x64", "makeappx.exe"),
22
+ path.join(root, "10.0.26100.0", "x64", "makeappx.exe"),
23
+ ]);
24
+ const found = findWindowsSdkTool("makeappx.exe", {
25
+ sdkRoot: root,
26
+ readdir: () => ["10.0.9.0", "10.0.26100.0"],
27
+ exists: (candidate) => present.has(candidate),
28
+ });
29
+ // String sort would pick 10.0.9.0 — that is the bug this guards.
30
+ assert.equal(found.version, "10.0.26100.0");
31
+ });
32
+
33
+ test("windows SDK lookup returns null when the tool is absent from every SDK", () => {
34
+ const found = findWindowsSdkTool("makeappx.exe", {
35
+ sdkRoot: "C:\\Kits",
36
+ readdir: () => ["10.0.26100.0"],
37
+ exists: (candidate) => candidate === "C:\\Kits",
38
+ });
39
+ assert.equal(found, null);
40
+ });
41
+
42
+ test("explicit OPENSSL_SRC_PERL wins over the default Strawberry location", () => {
43
+ const resolved = resolveWindowsPerl({
44
+ env: { OPENSSL_SRC_PERL: "D:\\perl\\bin\\perl.exe" },
45
+ exists: () => true,
46
+ });
47
+ assert.equal(resolved.perlPath, "D:\\perl\\bin\\perl.exe");
48
+ assert.equal(resolved.dir, "D:\\perl\\bin");
49
+ });
50
+
51
+ test("perl resolution reports nothing when no windows perl exists", () => {
52
+ assert.equal(resolveWindowsPerl({ env: {}, exists: () => false }), null);
53
+ });
54
+
55
+ test("vendored openssl is detected from the cargo lock", () => {
56
+ assert.equal(
57
+ graphVendorsOpenssl("Cargo.lock", { exists: () => true, read: () => 'name = "openssl-src"\n' }),
58
+ true,
59
+ );
60
+ assert.equal(
61
+ graphVendorsOpenssl("Cargo.lock", { exists: () => true, read: () => 'name = "rustls"\n' }),
62
+ false,
63
+ );
64
+ assert.equal(graphVendorsOpenssl("missing.lock", { exists: () => false }), false);
65
+ });
66
+
67
+ test("a sealed version collision suggests the next free patch, skipping taken ones", () => {
68
+ const result = nextFreeVersion("/sealed", "heardright", "0.1.33", {
69
+ exists: () => true,
70
+ readdir: () => [
71
+ "heardright-0.1.33-6f2f5e64",
72
+ "heardright-0.1.34-839ad700",
73
+ "heardright-0.1.36-f7035acc",
74
+ "viewright-0.1.35-aaaaaaaa",
75
+ ],
76
+ });
77
+ assert.equal(result.collision, true);
78
+ // 0.1.34 is taken, 0.1.35 is free — and viewright's record must not be counted.
79
+ assert.equal(result.suggestion, "0.1.35");
80
+ });
81
+
82
+ test("an unsealed version is reported as free", () => {
83
+ const result = nextFreeVersion("/sealed", "heardright", "0.1.37", {
84
+ exists: () => true,
85
+ readdir: () => ["heardright-0.1.33-6f2f5e64"],
86
+ });
87
+ assert.equal(result.collision, false);
88
+ assert.equal(result.suggestion, "0.1.37");
89
+ });
90
+
91
+ test("a sealed version collision is a hard failure carrying a concrete fix", () => {
92
+ const checks = collectPreflight({
93
+ platform: "linux",
94
+ repoRoot: process.cwd(),
95
+ app: "nothing-is-sealed-here",
96
+ version: "9.9.9",
97
+ });
98
+ // No sealed dir for this app, so the version check must pass rather than throw.
99
+ const version = checks.find((check) => check.name === "version");
100
+ assert.equal(version.status, "ok");
101
+ });
102
+
103
+ test("formatting surfaces the fix line only for non-ok checks", () => {
104
+ const text = formatPreflight([
105
+ { name: "a", status: "ok", detail: "fine", fix: "unused" },
106
+ { name: "b", status: PREFLIGHT_FAIL, detail: "broken", fix: "do the thing" },
107
+ ]);
108
+ assert.match(text, /\[ok {2}\] a: fine/);
109
+ assert.doesNotMatch(text, /fix: unused/);
110
+ assert.match(text, /fix: do the thing/);
111
+ });
112
+
113
+ test("failures are filtered from warnings so warnings never block a build", () => {
114
+ const failures = preflightFailures([
115
+ { name: "a", status: "warn", detail: "" },
116
+ { name: "b", status: PREFLIGHT_FAIL, detail: "" },
117
+ ]);
118
+ assert.deepEqual(failures.map((check) => check.name), ["b"]);
119
+ });
120
+
121
+ test("build PATH prefix is windows-only", () => {
122
+ assert.equal(buildPathPrefix({ platform: "mac" }), null);
123
+ });
package/release.mjs CHANGED
@@ -8,6 +8,7 @@ import { validateRightKitCargoContract } from "./cargo-contract.mjs";
8
8
  import { assertQaBackdoorContract } from "./qa-contract.mjs";
9
9
  import { assertLegalReleaseContract } from "./legal-contract.mjs";
10
10
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
11
+ import { collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
11
12
 
12
13
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
13
14
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -98,6 +99,34 @@ if (opts.platform === "win" && !target.sign?.files?.length) {
98
99
  fail(`${config.app ?? "app"} win must declare sign.files for the signed release pipeline`);
99
100
  }
100
101
  if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
102
+ /**
103
+ * Assemble preflight inputs from the app's own files. Kept here (not in
104
+ * preflight.mjs) so the check module stays free of release.mjs's config shape.
105
+ */
106
+ function releasePreflight({ config, root, repoRoot, platform }) {
107
+ let version;
108
+ try {
109
+ version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version;
110
+ } catch {
111
+ version = undefined;
112
+ }
113
+ // Every Cargo.lock the build compiles from; a vendored-OpenSSL edge in any of
114
+ // them means the build needs a real Windows Perl.
115
+ const cargoLockPaths = [
116
+ path.join(root, "src-tauri", "Cargo.lock"),
117
+ path.join(root, "Cargo.lock"),
118
+ ...(config.cargoLocks ?? []).map((entry) => path.resolve(root, entry)),
119
+ ];
120
+ return collectPreflight({
121
+ platform,
122
+ appRoot: root,
123
+ repoRoot,
124
+ app: config.app,
125
+ version,
126
+ cargoLockPaths,
127
+ });
128
+ }
129
+
101
130
  if (opts.doctor) {
102
131
  const { buildInputs } = resolveReleaseBuildInputs({
103
132
  repoRoot: doctorInvocation.repoRoot,
@@ -126,6 +155,16 @@ if (opts.doctor) {
126
155
  }
127
156
  if (target.sign?.files?.length) console.log(`sign: ${target.sign.files.join(", ")}`);
128
157
  if (target.publishBlocked) console.log(`publishBlocked: ${target.publishBlocked}`);
158
+
159
+ // Config above, MACHINE below. Printing config never told anyone whether this
160
+ // box could finish a build; these checks do.
161
+ const checks = releasePreflight({ config, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform });
162
+ console.log("preflight:");
163
+ console.log(formatPreflight(checks));
164
+ const failures = preflightFailures(checks);
165
+ if (failures.length > 0) {
166
+ fail(`preflight found ${failures.length} blocking problem(s); the build would fail on these`);
167
+ }
129
168
  process.exit(0);
130
169
  }
131
170
 
package/release.test.mjs CHANGED
@@ -238,7 +238,10 @@ test("doctor rejects a selected target with missing or empty buildInputs.include
238
238
  test("doctor passes a clean selected target", () => {
239
239
  const result = runDoctor(fixture());
240
240
  assert.equal(result.status, 0, result.stderr);
241
- assert.match(result.stdout, /right-release 0\.2\.45/);
241
+ // Derived from the version manifest, not hardcoded: a literal here has to be
242
+ // hand-edited on every bump and fails the suite when someone forgets.
243
+ const expected = versions.stagedNpm?.["@rightkit/release"] ?? versions.npm["@rightkit/release"];
244
+ assert.match(result.stdout, new RegExp(`right-release ${expected.replace(/\./g, "\\.")}`));
242
245
  });
243
246
 
244
247
  test("doctor permits unrelated dirt", () => {
@@ -432,7 +432,7 @@ test("every app platform requires effective non-empty build inputs", () => {
432
432
  test("RightKit exposes one current version manifest", () => {
433
433
  assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
434
434
  assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
435
- assert.equal(versions.npm["@rightkit/release"], "0.2.45");
435
+ assert.equal(versions.npm["@rightkit/release"], "0.2.47");
436
436
  assert.equal(versions.npm["@rightkit/legal"], "0.2.0");
437
437
  assert.equal(versions.npm["@rightkit/license"], "0.1.5");
438
438
  assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
@@ -444,11 +444,11 @@ test("RightKit exposes one current version manifest", () => {
444
444
  "@rightkit/license": "0.1.6",
445
445
  });
446
446
  assert.deepEqual(versions.legacyNpm, {
447
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44"],
447
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46"],
448
448
  });
449
449
  assert.ok(
450
450
  new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
451
- "previously published @rightkit/release versions must remain accepted during the 0.2.45 rollout",
451
+ "previously published @rightkit/release versions must remain accepted during the 0.2.46 rollout",
452
452
  );
453
453
  const licensePackage = JSON.parse(readFileSync(
454
454
  path.join(workspace, "tools/rightkit/packages/license/package.json"),
@@ -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.45",
10
+ "@rightkit/release": "0.2.47",
11
11
  "@rightkit/tauri": "0.1.0",
12
12
  "@rightkit/updates": "0.2.3"
13
13
  },
@@ -17,7 +17,7 @@
17
17
  "@rightkit/license": "0.1.6"
18
18
  },
19
19
  "legacyNpm": {
20
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44"]
20
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46"]
21
21
  },
22
22
  "cargo": {
23
23
  "rightkit-license": "0.1.2",
package/target-bridge.mjs CHANGED
@@ -1,9 +1,19 @@
1
1
  import { lstatSync, mkdirSync, realpathSync, symlinkSync, unlinkSync } from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ /**
5
+ * @param ownedRoot Directory this bridge owns (the app's per-fingerprint cache
6
+ * parent). An existing link resolving INSIDE it was created by this system for
7
+ * an earlier fingerprint, so it is safe to relink: a symlink is a pointer, not
8
+ * data, and the directory it pointed at is untouched. Without this, every
9
+ * fingerprint change — i.e. every Cargo.lock or version bump — left a stale
10
+ * link that hard-stopped the next build until someone deleted it by hand.
11
+ * Omit it to keep the strict refuse-everything behavior.
12
+ */
4
13
  export function createTargetBridge({
5
14
  link,
6
15
  target,
16
+ ownedRoot,
7
17
  platform = process.platform,
8
18
  lstat = lstatSync,
9
19
  mkdir = mkdirSync,
@@ -29,9 +39,40 @@ export function createTargetBridge({
29
39
  ownership = entryIdentity(entry);
30
40
  created = true;
31
41
  } else {
32
- if (!entry.isSymbolicLink()) throw new Error(`primary checkout target is not a symbolic link; refusing to replace it: ${link}`);
33
- if (!sameRealPath(link, target, realpath, platform)) {
34
- throw new Error(`primary checkout target is not the owned shared cache target; refusing to replace it: ${link}`);
42
+ if (!entry.isSymbolicLink()) {
43
+ throw new Error(
44
+ `primary checkout target is not a symbolic link; refusing to replace it: ${link}\n` +
45
+ ` It is a real directory holding build output. Delete it yourself if that output is disposable, then re-run.`,
46
+ );
47
+ }
48
+ // A dangling link (its cache entry was pruned) makes realpath throw, so
49
+ // this comparison must tolerate ENOENT rather than crash before the
50
+ // reclaim path below gets a chance to handle exactly that case.
51
+ let pointsAtTarget = false;
52
+ try {
53
+ pointsAtTarget = sameRealPath(link, target, realpath, platform);
54
+ } catch (error) {
55
+ if (error?.code !== "ENOENT") throw error;
56
+ }
57
+ if (!pointsAtTarget) {
58
+ // Stale link from an earlier fingerprint, or one whose cache entry a
59
+ // prune already removed. Both are ours to repoint.
60
+ if (isReclaimableLink(link, ownedRoot, realpath, platform)) {
61
+ unlink(link);
62
+ symlink(target, link, platform === "win32" ? "junction" : "dir");
63
+ entry = lstat(link);
64
+ if (!sameRealPath(link, target, realpath, platform)) {
65
+ throw new Error(`RightKit target bridge did not repoint to the owned shared cache target: ${link}`);
66
+ }
67
+ ownership = entryIdentity(entry);
68
+ created = true;
69
+ } else {
70
+ throw new Error(
71
+ `primary checkout target is not the owned shared cache target; refusing to replace it: ${link}\n` +
72
+ ` It is a symlink pointing outside this app's release cache, so it was not created by RightKit.\n` +
73
+ ` Repoint or remove it yourself, then re-run.`,
74
+ );
75
+ }
35
76
  }
36
77
  }
37
78
  acquired = true;
@@ -110,6 +151,29 @@ function readEntry(file, lstat) {
110
151
  }
111
152
  }
112
153
 
154
+ /**
155
+ * True when `link` is a symlink RightKit may repoint: it resolves inside
156
+ * `ownedRoot`, or it dangles entirely (its cache entry was pruned). A link
157
+ * resolving anywhere else is someone else's and is never touched.
158
+ */
159
+ function isReclaimableLink(link, ownedRoot, realpath, platform) {
160
+ if (!ownedRoot) return false;
161
+ const canonical = (value) => {
162
+ const resolved = path.normalize(value);
163
+ return platform === "win32" ? resolved.toLowerCase() : resolved;
164
+ };
165
+ let resolved;
166
+ try {
167
+ resolved = canonical(realpath(link));
168
+ } catch (error) {
169
+ // Dangling link: the entry it pointed at is gone, so nothing can be lost.
170
+ if (error?.code === "ENOENT") return true;
171
+ throw error;
172
+ }
173
+ const root = canonical(ownedRoot);
174
+ return resolved === root || resolved.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`);
175
+ }
176
+
113
177
  function sameRealPath(left, right, realpath, platform) {
114
178
  const canonical = (value) => {
115
179
  const resolved = path.normalize(realpath(value));
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
@@ -153,3 +153,54 @@ test("cleanup is idempotent and reports process errors without masking a build f
153
153
  assert.equal(lstatSync(fx.link).isSymbolicLink(), true);
154
154
  unlinkSync(fx.link);
155
155
  });
156
+
157
+ test("a stale link from an earlier fingerprint is repointed, not a hard stop", () => {
158
+ const dir = mkdtempSync(path.join(os.tmpdir(), "bridge-stale-"));
159
+ const ownedRoot = path.join(dir, "cache", "heardright");
160
+ const oldTarget = path.join(ownedRoot, "aaaaaaaa");
161
+ const newTarget = path.join(ownedRoot, "bbbbbbbb");
162
+ mkdirSync(oldTarget, { recursive: true });
163
+ const link = path.join(dir, "target");
164
+ symlinkSync(oldTarget, link, "junction");
165
+
166
+ const bridge = createTargetBridge({ link, target: newTarget, ownedRoot });
167
+ const result = bridge.ensure();
168
+
169
+ assert.equal(result.created, true);
170
+ assert.equal(realpathSync(link), realpathSync(newTarget));
171
+ // The directory the stale link pointed at must survive: it is cache, and the
172
+ // link was only ever a pointer to it.
173
+ assert.equal(existsSync(oldTarget), true);
174
+ rmSync(dir, { recursive: true, force: true });
175
+ });
176
+
177
+ test("a link pointing outside the owned root is still refused", () => {
178
+ const dir = mkdtempSync(path.join(os.tmpdir(), "bridge-foreign-"));
179
+ const ownedRoot = path.join(dir, "cache", "heardright");
180
+ const foreign = path.join(dir, "somewhere-else");
181
+ const newTarget = path.join(ownedRoot, "bbbbbbbb");
182
+ mkdirSync(foreign, { recursive: true });
183
+ const link = path.join(dir, "target");
184
+ symlinkSync(foreign, link, "junction");
185
+
186
+ const bridge = createTargetBridge({ link, target: newTarget, ownedRoot });
187
+ assert.throws(() => bridge.ensure(), /refusing to replace it/);
188
+ assert.equal(realpathSync(link), realpathSync(foreign));
189
+ rmSync(dir, { recursive: true, force: true });
190
+ });
191
+
192
+ test("a dangling link whose cache entry was pruned is reclaimed", () => {
193
+ const dir = mkdtempSync(path.join(os.tmpdir(), "bridge-dangling-"));
194
+ const ownedRoot = path.join(dir, "cache", "heardright");
195
+ const pruned = path.join(ownedRoot, "aaaaaaaa");
196
+ const newTarget = path.join(ownedRoot, "bbbbbbbb");
197
+ mkdirSync(pruned, { recursive: true });
198
+ const link = path.join(dir, "target");
199
+ symlinkSync(pruned, link, "junction");
200
+ rmSync(pruned, { recursive: true, force: true });
201
+
202
+ const bridge = createTargetBridge({ link, target: newTarget, ownedRoot });
203
+ assert.equal(bridge.ensure().created, true);
204
+ assert.equal(realpathSync(link), realpathSync(newTarget));
205
+ rmSync(dir, { recursive: true, force: true });
206
+ });