@rightkit/release 0.2.45 → 0.2.46
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 +44 -0
- package/package.json +1 -1
- package/preflight.mjs +278 -0
- package/preflight.test.mjs +123 -0
- package/release.mjs +39 -0
- package/release.test.mjs +4 -1
- package/right-suite-contract.test.mjs +3 -3
- package/rightkit-versions.json +2 -2
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
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.46",
|
|
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
|
-
|
|
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.
|
|
435
|
+
assert.equal(versions.npm["@rightkit/release"], "0.2.46");
|
|
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"],
|
|
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.
|
|
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"),
|
package/rightkit-versions.json
CHANGED
|
@@ -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.
|
|
10
|
+
"@rightkit/release": "0.2.46",
|
|
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"]
|
|
21
21
|
},
|
|
22
22
|
"cargo": {
|
|
23
23
|
"rightkit-license": "0.1.2",
|