@rightkit/release 0.2.174 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +200 -2
  2. package/addon-command.mjs +3 -4
  3. package/android-play-upload.mjs +2 -1
  4. package/apple-profile-renew.mjs +11 -3
  5. package/artifact-storage.mjs +35 -2
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +1 -1
  8. package/build-release.mjs +138 -47
  9. package/cli/right-release.mjs +40 -5
  10. package/codesign-environment.mjs +2 -2
  11. package/command-spec.mjs +80 -0
  12. package/content-digest.mjs +209 -0
  13. package/deps-cargo.mjs +55 -0
  14. package/deps.mjs +7 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/dist-scan.mjs +102 -0
  21. package/dmg-build.mjs +53 -4
  22. package/github-updater-release.mjs +70 -7
  23. package/icon-pack.mjs +117 -0
  24. package/in-place-install.mjs +17 -14
  25. package/init-command.mjs +116 -0
  26. package/install-app-windows.mjs +24 -7
  27. package/install-app.mjs +56 -42
  28. package/ios-asc-credentials.mjs +163 -0
  29. package/ios-build-context.mjs +129 -0
  30. package/ios-build-upload.mjs +282 -0
  31. package/ios-credentials-command.mjs +70 -0
  32. package/ios-dev.mjs +248 -0
  33. package/ios-host.mjs +76 -0
  34. package/ios-lock.mjs +75 -0
  35. package/ios-package-recipe.mjs +707 -0
  36. package/ios-testflight.mjs +266 -0
  37. package/ios-upload.mjs +313 -0
  38. package/local-publish.mjs +390 -47
  39. package/macho-approval-hash.mjs +181 -0
  40. package/managed-cargo.mjs +57 -1
  41. package/media-sidecar.mjs +20 -4
  42. package/mirror-root-artifact.mjs +74 -22
  43. package/native-cargo-layout.mjs +47 -4
  44. package/npm-license-notices.mjs +159 -0
  45. package/ownership-command.mjs +2 -1
  46. package/ownership-dependency-allowlist.json +2 -1
  47. package/ownership-evidence.mjs +30 -18
  48. package/ownership-key-command.mjs +69 -2
  49. package/ownership-mandatory-set.mjs +66 -16
  50. package/ownership-repositories.json +1 -1
  51. package/ownership-scope.mjs +94 -0
  52. package/ownership.mjs +86 -11
  53. package/package-recipe-targets.mjs +56 -2
  54. package/package.json +1 -1
  55. package/pe-content.mjs +133 -0
  56. package/preflight.mjs +14 -7
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +83 -5
  61. package/publish-cargo.mjs +124 -18
  62. package/publish-update.mjs +15 -2
  63. package/qa-build-features.mjs +16 -13
  64. package/qa-contract.mjs +30 -2
  65. package/qa-control-scan.mjs +4 -0
  66. package/release-dependencies-command.mjs +45 -0
  67. package/release-dependencies.mjs +60 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +62 -24
  71. package/release.mjs +144 -21
  72. package/rightkit-ownership.json +46 -2
  73. package/rightkit-versions.json +380 -189
  74. package/runtime-budgets.mjs +134 -23
  75. package/sign-updater.mjs +56 -59
  76. package/source-gate.mjs +8 -1
  77. package/stage-sidecar.mjs +634 -69
  78. package/target-distribution.mjs +5 -0
  79. package/test-command.mjs +190 -0
  80. package/toolchain-swift.mjs +239 -0
  81. package/upload-release.mjs +7 -2
  82. package/web-build.mjs +67 -0
@@ -0,0 +1,5 @@
1
+ // The distribution an upload uses for one platform. An app that ships desktop and mobile has a different store per
2
+ // platform, so `targets.<platform>.distribution` wins; the top-level `distribution` is the single-store fallback.
3
+ export function targetDistribution(config, platform) {
4
+ return config?.targets?.[platform]?.distribution ?? config?.distribution;
5
+ }
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ // `right-release test [--config <file>] [--suite <name>[,<name>]]... [--runner <name>] [--list] [--json] [-- <args>]`
3
+ // runs the app's declared test suites from `right-release.config.mjs`, one after another, and stops at the first failure.
4
+ //
5
+ // tests: {
6
+ // eventPrefix: "e2e", // optional; events are `<prefix>_suite_started` / `<prefix>_suite_completed`
7
+ // runners: { // optional named bases a suite or a `--runner` call builds on
8
+ // vitest: { node: "node_modules/vitest/vitest.mjs", nodeFlags: ["--no-experimental-webstorage"], nodeOptions: ["--no-experimental-webstorage"] },
9
+ // },
10
+ // suites: [
11
+ // { name: "unit", runner: "vitest", args: ["run", "--project", "unit"] },
12
+ // { name: "runtime-config", node: "scripts/test-runtime-config-e2e.test.mjs", nodeFlags: ["--test"] },
13
+ // // Expands to journeys-1, journeys-2, ...: the listed journeys run `chunk` at a time in separate processes.
14
+ // { name: "journeys", node: "scripts/qa-browser-functional.mjs", args: ["--keep-going"],
15
+ // journeys: { file: "scripts/qa-e2e-journeys.json", key: "journeys", chunk: 12, flag: "--journey" } },
16
+ // ],
17
+ // }
18
+ //
19
+ // A step is `{ cmd | node, args?, cwd?, env?, nodeFlags?, nodeOptions? }` (see command-spec.mjs); a suite adds `name`,
20
+ // optionally `runner` (its arguments and flags are appended to the runner's) and `journeys`. Everything runs without a shell
21
+ // from the app root. `--suite journeys` selects every chunk of that group. `--runner vitest -- run --project unit` runs
22
+ // the runner once with the arguments after `--` (the watch or one-off case); `-- <args>` after `--suite` is allowed when
23
+ // exactly one suite is selected. Exit code: 0 when every selected suite passed, else the failing suite's exit code.
24
+ import { readFileSync } from "node:fs";
25
+ import path from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+ import { spawn } from "./background-process.mjs";
28
+ import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
29
+
30
+ const fail = (message) => { throw new CommandSpecError(message); };
31
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
32
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
33
+ const PREFIX = /^[a-z][a-z0-9_]*$/;
34
+ const uniq = (...lists) => [...new Set(lists.flatMap((list) => list ?? []))];
35
+
36
+ export function parseTestArgs(argv) {
37
+ const opts = { config: "right-release.config.mjs", suites: [], runner: undefined, list: false, json: false, passthrough: [] };
38
+ for (let i = 0; i < argv.length; i += 1) {
39
+ const arg = argv[i];
40
+ if (arg === "--") { opts.passthrough = argv.slice(i + 1); break; }
41
+ if (arg === "--config" || arg === "--suite" || arg === "--runner") {
42
+ const value = argv[i + 1];
43
+ if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
44
+ if (arg === "--suite") opts.suites.push(...value.split(",").filter(Boolean));
45
+ else opts[arg.slice(2)] = value;
46
+ i += 1;
47
+ } else if (arg === "--list") opts.list = true;
48
+ else if (arg === "--json") opts.json = true;
49
+ else throw new Error(`unknown test argument: ${arg}`);
50
+ }
51
+ return opts;
52
+ }
53
+
54
+ function mergeRunner(runner, entry) {
55
+ const { name: _name, runner: _runner, journeys: _journeys, ...own } = entry;
56
+ return {
57
+ ...runner,
58
+ ...own,
59
+ args: [...(runner?.args ?? []), ...(own.args ?? [])],
60
+ nodeFlags: uniq(runner?.nodeFlags, own.nodeFlags),
61
+ nodeOptions: uniq(runner?.nodeOptions, own.nodeOptions),
62
+ env: { ...(runner?.env ?? {}), ...(own.env ?? {}) },
63
+ // A suite that names its own executable replaces the runner's, whichever form each uses.
64
+ ...(runner?.node !== undefined && own.cmd !== undefined ? { node: undefined } : {}),
65
+ ...(runner?.cmd !== undefined && own.node !== undefined ? { cmd: undefined } : {}),
66
+ };
67
+ }
68
+
69
+ function expandJourneys(spec, label, appRoot) {
70
+ if (!isObject(spec)) fail(`${label}.journeys must be an object`);
71
+ const unknown = Object.keys(spec).filter((key) => !["file", "key", "chunk", "flag"].includes(key));
72
+ if (unknown.length) fail(`${label}.journeys has unknown key(s): ${unknown.join(", ")} (allowed: file, key, chunk, flag)`);
73
+ if (typeof spec.file !== "string" || !spec.file.trim()) fail(`${label}.journeys.file must be a relative JSON path`);
74
+ if (!Number.isInteger(spec.chunk) || spec.chunk < 1) fail(`${label}.journeys.chunk must be a positive integer`);
75
+ if (typeof spec.flag !== "string" || !spec.flag.startsWith("--")) fail(`${label}.journeys.flag must be a flag such as --journey`);
76
+ let data;
77
+ try { data = JSON.parse(readFileSync(path.resolve(appRoot, spec.file), "utf8")); }
78
+ catch (error) { fail(`${label}.journeys.file cannot be read: ${error.message}`); }
79
+ const list = (spec.key ?? "journeys") === "" ? data : data?.[spec.key ?? "journeys"];
80
+ if (!Array.isArray(list) || list.some((item) => typeof item !== "string" || item.includes(","))) fail(`${label}.journeys: ${spec.file} must hold a list of names without commas under "${spec.key ?? "journeys"}"`);
81
+ const chunks = [];
82
+ for (let index = 0; index < list.length; index += spec.chunk) chunks.push(list.slice(index, index + spec.chunk));
83
+ return chunks.map((names) => [spec.flag, names.join(",")]);
84
+ }
85
+
86
+ /**
87
+ * The ordered suites a run would execute: `[{ name, group, step, extraArgs }]`. `step` is merged with its runner and
88
+ * validated; journey chunks are already expanded. Pure apart from reading a declared journeys file.
89
+ */
90
+ export function planTests(config, { suites = [], runner, passthrough = [], appRoot }) {
91
+ const tests = config?.tests;
92
+ if (!isObject(tests)) fail("the config declares no tests");
93
+ const unknown = Object.keys(tests).filter((key) => !["suites", "runners", "eventPrefix"].includes(key));
94
+ if (unknown.length) fail(`tests has unknown key(s): ${unknown.join(", ")} (allowed: suites, runners, eventPrefix)`);
95
+ const runners = tests.runners ?? {};
96
+ if (!isObject(runners)) fail("tests.runners must be an object");
97
+ for (const [name, spec] of Object.entries(runners)) {
98
+ if (!NAME.test(name)) fail(`tests.runners.${name}: names use letters, digits, . _ -`);
99
+ validateStep(spec, `tests.runners.${name}`);
100
+ }
101
+ if (runner !== undefined) {
102
+ if (!runners[runner]) fail(`unknown runner: ${runner} (declared: ${Object.keys(runners).join(", ") || "none"})`);
103
+ if (suites.length) fail("use --runner or --suite, not both");
104
+ const step = mergeRunner(runners[runner], {});
105
+ return [{ name: runner, group: runner, step, extraArgs: [...passthrough] }];
106
+ }
107
+ if (!Array.isArray(tests.suites) || tests.suites.length === 0) fail("tests.suites must be a non-empty list");
108
+ const planned = [];
109
+ const seen = new Set();
110
+ tests.suites.forEach((entry, index) => {
111
+ const label = `tests.suites[${index}]`;
112
+ validateStep(entry, label, ["name", "runner", "journeys"]);
113
+ if (typeof entry.name !== "string" || !NAME.test(entry.name)) fail(`${label}.name must be set (letters, digits, . _ -)`);
114
+ if (entry.runner !== undefined && !runners[entry.runner]) fail(`${label}.runner names an undeclared runner: ${entry.runner}`);
115
+ const base = mergeRunner(entry.runner === undefined ? undefined : runners[entry.runner], entry);
116
+ if (base.cmd === undefined && base.node === undefined) fail(`${label} needs cmd, node or a runner that has one`);
117
+ const variants = entry.journeys === undefined
118
+ ? [{ name: entry.name, extra: [] }]
119
+ : expandJourneys(entry.journeys, label, appRoot).map((extra, chunk) => ({ name: `${entry.name}-${chunk + 1}`, extra }));
120
+ for (const variant of variants) {
121
+ if (seen.has(variant.name)) fail(`${label}: duplicate suite name ${variant.name}`);
122
+ seen.add(variant.name);
123
+ planned.push({ name: variant.name, group: entry.name, step: { ...base, args: [...base.args, ...variant.extra] }, extraArgs: [] });
124
+ }
125
+ });
126
+ let selected = planned;
127
+ if (suites.length) {
128
+ selected = planned.filter((item) => suites.includes(item.name) || suites.includes(item.group));
129
+ const missing = suites.filter((name) => !planned.some((item) => item.name === name || item.group === name));
130
+ if (missing.length) fail(`unknown suite: ${missing.join(", ")} (declared: ${[...new Set(planned.map((item) => item.group))].join(", ")})`);
131
+ }
132
+ if (passthrough.length) {
133
+ if (selected.length !== 1) fail("arguments after -- need exactly one selected suite (or use --runner)");
134
+ selected = [{ ...selected[0], extraArgs: [...passthrough] }];
135
+ }
136
+ return selected;
137
+ }
138
+
139
+ export function eventPrefix(config) {
140
+ const prefix = config?.tests?.eventPrefix ?? "test";
141
+ if (typeof prefix !== "string" || !PREFIX.test(prefix)) fail("tests.eventPrefix must be lower-case letters, digits and underscores");
142
+ return prefix;
143
+ }
144
+
145
+ /**
146
+ * Run the planned suites in order; stop at the first non-zero exit. `spawnFn` and `log` are injectable.
147
+ * Resolves `{ exitCode, results: [{ suite, exitCode, durationMs }] }`; a spawn error or a signal rejects.
148
+ */
149
+ export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log } = {}) {
150
+ const results = [];
151
+ for (const item of plan) {
152
+ const invocation = resolveInvocation(item.step, { appRoot, baseEnv: env, extraArgs: item.extraArgs });
153
+ log(JSON.stringify({ event: `${prefix}_suite_started`, suite: item.name }));
154
+ const started = Date.now();
155
+ const exitCode = await new Promise((resolve, reject) => {
156
+ const child = spawnFn(invocation.cmd, invocation.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
157
+ child.once("error", reject);
158
+ child.once("exit", (code, signal) => {
159
+ if (signal) reject(new Error(`${item.name} stopped by ${signal}`));
160
+ else resolve(code ?? 1);
161
+ });
162
+ });
163
+ const durationMs = Date.now() - started;
164
+ log(JSON.stringify({ event: `${prefix}_suite_completed`, suite: item.name, exitCode, durationMs }));
165
+ results.push({ suite: item.name, exitCode, durationMs });
166
+ if (exitCode !== 0) return { exitCode, results };
167
+ }
168
+ return { exitCode: 0, results };
169
+ }
170
+
171
+ export async function main(argv = process.argv.slice(2)) {
172
+ const opts = parseTestArgs(argv);
173
+ const { config, appRoot } = await loadAppConfig(opts.config);
174
+ const plan = planTests(config, { suites: opts.suites, runner: opts.runner, passthrough: opts.passthrough, appRoot });
175
+ if (opts.list) {
176
+ const view = plan.map((item) => ({ suite: item.name, ...resolveInvocation(item.step, { appRoot, baseEnv: {}, extraArgs: item.extraArgs }), env: undefined }));
177
+ if (opts.json) console.log(JSON.stringify(view, null, 2));
178
+ else for (const item of view) console.log(`${item.suite}: ${[item.cmd, ...item.args].join(" ")}`);
179
+ return 0;
180
+ }
181
+ const { exitCode } = await runSuites(plan, { appRoot, prefix: eventPrefix(config) });
182
+ return exitCode;
183
+ }
184
+
185
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
186
+ main().then((code) => { process.exitCode = code; }, (error) => {
187
+ console.error(`right-release test: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
188
+ process.exitCode = 1;
189
+ });
190
+ }
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ // `right-release toolchain swift --version <x.y[.z]> --sha256 <pin> [--url <https url>] [--github-env] [--json]`
3
+ // (Orthic-Labs/rightkit#436): the one owner of the pinned Swift toolchain install on Windows, replacing the swift.org installer
4
+ // block copied into SwiftRight's workflows and release script.
5
+ //
6
+ // 1. download the swift.org Windows release installer (default URL derived from the version), or reuse a file already on disk;
7
+ // 2. refuse unless its sha256 equals the pin (checked before anything runs, and a bad download is deleted);
8
+ // 3. skip the install when the pinned version is already the swift.exe on PATH, else run it silently (`/quiet /norestart`;
9
+ // exit 0 and 3010 pass, anything else prints the log tail and fails);
10
+ // 4. find swift.exe (the refreshed Machine/User PATH, then the installer's Toolchains folders), require `swift --version` to
11
+ // report the pinned version, and find the Swift runtime bin (`Runtimes\\<ver>\\usr\\bin`, marked by swiftCore.dll);
12
+ // 5. with `--github-env` (GITHUB_ENV / GITHUB_PATH set), export what the installer changed: variables it added or changed in
13
+ // the Machine/User environment (SDKROOT and friends; values are never printed, only names) and the new PATH entries,
14
+ // the swift directory and the runtime bin.
15
+ //
16
+ // Windows only. It never signs, never builds and reads no secret; the sha256 pin is the trust anchor, not the URL.
17
+ import { createHash } from "node:crypto";
18
+ import nodeFs from "node:fs";
19
+ import os from "node:os";
20
+ import nodePath from "node:path";
21
+ import { pathToFileURL } from "node:url";
22
+ import { Readable } from "node:stream";
23
+ import { pipeline } from "node:stream/promises";
24
+ import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
25
+
26
+ const win = nodePath.win32;
27
+
28
+ export class ToolchainError extends Error {
29
+ constructor(code, message, details = {}) { super(message); this.name = "ToolchainError"; this.code = code; this.details = details; }
30
+ }
31
+ const fail = (code, message, details) => { throw new ToolchainError(code, message, details); };
32
+
33
+ const VERSION = /^\d+\.\d+(?:\.\d+)?$/;
34
+ const SHA256 = /^[0-9A-Fa-f]{64}$/;
35
+ /** Variables the installer may legitimately set; these names are never exported to later steps even when they changed. */
36
+ const NEVER_EXPORT = /^(?:GITHUB_|RUNNER_|ACTIONS_|NODE_OPTIONS$|BASH_ENV$|ENV$|COMSPEC$|PATHEXT$|TEMP$|TMP$|PATH$)/i;
37
+
38
+ /** swift.org release installer URL for a version (x86_64 Windows 10+). */
39
+ export function swiftInstallerUrl(version) {
40
+ return `https://download.swift.org/swift-${version}-release/windows10/swift-${version}-RELEASE/swift-${version}-RELEASE-windows10.exe`;
41
+ }
42
+
43
+ /** Validate the options; returns the normalized { version, sha256 (lower case), url, githubEnv, json }. */
44
+ export function resolveSwiftOptions(raw) {
45
+ const errors = [];
46
+ if (typeof raw.version !== "string" || !VERSION.test(raw.version)) errors.push("--version must be a Swift release such as 6.4.0");
47
+ if (typeof raw.sha256 !== "string" || !SHA256.test(raw.sha256)) errors.push("--sha256 is required: the 64-hex sha256 of the swift.org installer for this version");
48
+ if (raw.url !== undefined && !/^https:\/\/[^\s/]+\/\S*$/.test(raw.url)) errors.push("--url must be an https:// URL");
49
+ if (errors.length) fail("TOOLCHAIN_OPTIONS_INVALID", errors.join("; "));
50
+ return { version: raw.version, sha256: raw.sha256.toLowerCase(), url: raw.url ?? swiftInstallerUrl(raw.version), githubEnv: raw.githubEnv === true, json: raw.json === true };
51
+ }
52
+
53
+ export function parseToolchainArgs(argv) {
54
+ if (argv[0] !== "swift") throw new Error("expected: toolchain swift --version <x.y.z> --sha256 <pin> [--url <https url>] [--github-env] [--json]");
55
+ const opts = {};
56
+ for (let i = 1; i < argv.length; i += 1) {
57
+ const arg = argv[i];
58
+ if (arg === "--version" || arg === "--sha256" || arg === "--url") {
59
+ const value = argv[i + 1];
60
+ if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
61
+ opts[arg.slice(2)] = value;
62
+ i += 1;
63
+ } else if (arg === "--github-env") opts.githubEnv = true;
64
+ else if (arg === "--json") opts.json = true;
65
+ else throw new Error(`unknown toolchain argument: ${arg}`);
66
+ }
67
+ return opts;
68
+ }
69
+
70
+ /**
71
+ * What an installer run changed: variables whose Machine or User value is new or different (never PATH, never a denied name,
72
+ * never a name or value GITHUB_ENV cannot carry on one line), and the PATH entries it added. Pure.
73
+ */
74
+ export function diffInstallerEnvironment(before, after) {
75
+ const changed = {};
76
+ for (const level of ["Machine", "User"]) {
77
+ for (const [name, value] of Object.entries(after?.[level] ?? {})) {
78
+ if (/^path$/i.test(name) || NEVER_EXPORT.test(name) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
79
+ if (typeof value !== "string" || /[\r\n]/.test(value)) continue;
80
+ if (before?.[level]?.[name] !== value) changed[name] = value;
81
+ }
82
+ }
83
+ const entries = (environment) => ["Machine", "User"].flatMap((level) => String(Object.entries(environment?.[level] ?? {}).find(([name]) => /^path$/i.test(name))?.[1] ?? "").split(";")).map((entry) => entry.trim()).filter(Boolean);
84
+ const known = new Set(entries(before).map((entry) => entry.toLowerCase()));
85
+ const addedPath = [...new Set(entries(after))].filter((entry) => !known.has(entry.toLowerCase()));
86
+ return { changed, addedPath };
87
+ }
88
+
89
+ const READ_ENVIRONMENT = "$ErrorActionPreference='Stop'; $r=@{}; foreach($l in 'Machine','User'){ $h=@{}; foreach($e in [Environment]::GetEnvironmentVariables($l).GetEnumerator()){ $h[[string]$e.Key]=[string]$e.Value }; $r[$l]=$h }; $r | ConvertTo-Json -Compress -Depth 4";
90
+
91
+ /** The Machine and User environment as the registry holds it now ({ Machine: {...}, User: {...} }). */
92
+ export function readInstallerEnvironment(run = nodeSpawnSync) {
93
+ const result = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", READ_ENVIRONMENT], { encoding: "utf8", windowsHide: true, shell: false });
94
+ if (result?.error || result?.status !== 0) fail("TOOLCHAIN_ENVIRONMENT_UNREADABLE", `cannot read the Machine/User environment (${result?.error?.message ?? `exit ${result?.status}`})`);
95
+ try { return JSON.parse(String(result.stdout)); } catch { return fail("TOOLCHAIN_ENVIRONMENT_UNREADABLE", "the environment listing was not JSON"); }
96
+ }
97
+
98
+ /** Depth-limited search for `name` below `root` (the installer's Toolchains folder). */
99
+ function findBelow(fs, root, name, depth = 6) {
100
+ if (depth < 0 || !fs.existsSync(root)) return undefined;
101
+ let entries;
102
+ try { entries = [...fs.readdirSync(root)].sort(); } catch { return undefined; }
103
+ if (entries.includes(name)) return win.join(root, name);
104
+ for (const entry of entries) {
105
+ const child = win.join(root, entry);
106
+ let isDirectory = false;
107
+ try { isDirectory = fs.statSync(child).isDirectory(); } catch { isDirectory = false; }
108
+ if (isDirectory) { const hit = findBelow(fs, child, name, depth - 1); if (hit) return hit; }
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ async function defaultDownload(url, file, { fs = nodeFs } = {}) {
114
+ const response = await fetch(url, { redirect: "follow" });
115
+ if (!response.ok || !response.body) fail("TOOLCHAIN_DOWNLOAD_FAILED", `GET ${url} failed with ${response.status}`, { url });
116
+ if (typeof response.url === "string" && response.url && !response.url.startsWith("https://")) fail("TOOLCHAIN_DOWNLOAD_FAILED", `${url} redirected to a non-https URL`, { url });
117
+ const hash = createHash("sha256");
118
+ const temporary = `${file}.download`;
119
+ const sink = fs.createWriteStream(temporary);
120
+ await pipeline(Readable.fromWeb(response.body), async function* tap(source) { for await (const chunk of source) { hash.update(chunk); yield chunk; } }, sink);
121
+ fs.renameSync(temporary, file);
122
+ return hash.digest("hex");
123
+ }
124
+
125
+ const sha256OfFile = (fs, file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
126
+
127
+ /**
128
+ * Install (or confirm) the pinned Swift toolchain. Every effect is injectable: platform, env, fs, run, download(url, file) ->
129
+ * sha256 hex, tmpdir, log. Returns { version, swift, runtimeBin, installed, exported: { variables: [names], path: [entries] } }.
130
+ */
131
+ export async function installSwiftToolchain(rawOptions, deps = {}) {
132
+ const options = resolveSwiftOptions(rawOptions);
133
+ const platform = deps.platform ?? process.platform;
134
+ if (platform !== "win32") fail("TOOLCHAIN_HOST_UNSUPPORTED", `right-release toolchain swift installs the Windows toolchain only (host: ${platform}); install Swift ${options.version} with the platform's own tooling`);
135
+ const env = deps.env ?? process.env;
136
+ const fs = deps.fs ?? nodeFs;
137
+ const run = deps.run ?? nodeSpawnSync;
138
+ const log = deps.log ?? (() => {});
139
+ const swiftVersionOk = (swift, pathEnv) => {
140
+ const result = run(swift, ["--version"], { encoding: "utf8", windowsHide: true, shell: false, env: { ...env, Path: pathEnv, PATH: pathEnv } });
141
+ const text = `${result?.stdout ?? ""}${result?.stderr ?? ""}`;
142
+ if (result?.error || result?.status !== 0) return { ok: false, text };
143
+ return { ok: new RegExp(`Swift version ${options.version.replaceAll(".", "\\.")}(?!\\d)`).test(text), text };
144
+ };
145
+ const currentPath = env.Path ?? env.PATH ?? "";
146
+ const onPath = (pathEnv) => pathEnv.split(";").map((dir) => dir.trim()).filter(Boolean).map((dir) => win.join(dir, "swift.exe")).find((candidate) => fs.existsSync(candidate));
147
+
148
+ // Already the pinned version: nothing to install. The pin is still the contract for a fresh install below.
149
+ let installed = false;
150
+ let swift = onPath(currentPath);
151
+ let pathEnv = currentPath;
152
+ let before;
153
+ let after;
154
+ if (swift && swiftVersionOk(swift, currentPath).ok) log(`swift ${options.version} already installed at ${swift}`);
155
+ else {
156
+ swift = undefined;
157
+ const temp = deps.tmpdir ?? env.RUNNER_TEMP ?? os.tmpdir();
158
+ const installer = win.join(temp, `swift-${options.version}-RELEASE-windows10.exe`);
159
+ if (!(fs.existsSync(installer) && sha256OfFile(fs, installer) === options.sha256)) {
160
+ log(`downloading ${options.url}`);
161
+ const download = deps.download ?? ((url, file) => defaultDownload(url, file, { fs }));
162
+ const actual = String(await download(options.url, installer)).toLowerCase();
163
+ if (actual !== options.sha256) {
164
+ try { fs.rmSync(installer, { force: true }); } catch { /* best effort */ }
165
+ fail("TOOLCHAIN_HASH_MISMATCH", `Swift installer sha256 ${actual} does not match the pin ${options.sha256}; the download was removed and nothing was installed`, { expected: options.sha256, actual });
166
+ }
167
+ }
168
+ // Hash the bytes that will run, not the bytes that were streamed: a file reused from disk was checked above.
169
+ const onDisk = sha256OfFile(fs, installer);
170
+ if (onDisk !== options.sha256) fail("TOOLCHAIN_HASH_MISMATCH", `Swift installer on disk sha256 ${onDisk} does not match the pin ${options.sha256}`, { expected: options.sha256, actual: onDisk });
171
+ before = readInstallerEnvironment(run);
172
+ const installLog = win.join(temp, "swift-install.log");
173
+ const result = run(installer, ["/quiet", "/norestart", "/log", installLog], { encoding: "utf8", windowsHide: true, shell: false });
174
+ if (result?.error) fail("TOOLCHAIN_INSTALL_FAILED", `could not start the Swift installer: ${result.error.message}`);
175
+ if (result.status !== 0 && result.status !== 3010) {
176
+ let tail = "";
177
+ try { tail = String(fs.readFileSync(installLog)).split(/\r?\n/).slice(-60).join("\n"); } catch { /* no log */ }
178
+ fail("TOOLCHAIN_INSTALL_FAILED", `Swift installer failed with exit code ${result.status}${tail ? `\n${tail}` : ""}`, { exitCode: result.status });
179
+ }
180
+ installed = true;
181
+ after = readInstallerEnvironment(run);
182
+ const refreshed = diffInstallerEnvironment(before, after).addedPath;
183
+ pathEnv = [...new Set([...currentPath.split(";").filter(Boolean), ...["Machine", "User"].flatMap((level) => String(Object.entries(after[level] ?? {}).find(([name]) => /^path$/i.test(name))?.[1] ?? "").split(";").filter(Boolean)), ...refreshed])].join(";");
184
+ swift = onPath(pathEnv);
185
+ if (!swift) {
186
+ for (const root of [win.join(env.LOCALAPPDATA ?? "", "Programs", "Swift"), win.join(env.ProgramFiles ?? "C:\\Program Files", "Swift")]) {
187
+ swift = findBelow(fs, win.join(root, "Toolchains"), "swift.exe");
188
+ if (swift) { pathEnv = `${win.dirname(swift)};${pathEnv}`; break; }
189
+ }
190
+ }
191
+ if (!swift) fail("TOOLCHAIN_SWIFT_NOT_FOUND", "swift.exe was not found after the Swift installer ran");
192
+ const probe = swiftVersionOk(swift, pathEnv);
193
+ if (!probe.ok) fail("TOOLCHAIN_VERSION_MISMATCH", `the installed swift.exe does not report Swift version ${options.version}: ${probe.text.trim().split(/\r?\n/)[0] ?? "no output"}`, { swift });
194
+ }
195
+
196
+ // Runtimes\<version>\usr\bin is a sibling of Toolchains under the install root (swiftCore.dll marks it).
197
+ let installRoot;
198
+ for (let cursor = win.dirname(swift); cursor && win.dirname(cursor) !== cursor; cursor = win.dirname(cursor)) {
199
+ if (win.basename(cursor).toLowerCase() === "toolchains") { installRoot = win.dirname(cursor); break; }
200
+ }
201
+ let runtimeBin;
202
+ if (installRoot) {
203
+ const runtimes = win.join(installRoot, "Runtimes");
204
+ let versions = [];
205
+ try { versions = [...fs.readdirSync(runtimes)].sort(); } catch { versions = []; }
206
+ runtimeBin = versions.map((entry) => win.join(runtimes, entry, "usr", "bin")).find((dir) => fs.existsSync(win.join(dir, "swiftCore.dll")));
207
+ }
208
+ if (!runtimeBin) fail("TOOLCHAIN_RUNTIME_NOT_FOUND", `no Swift runtime directory (swiftCore.dll) beside ${swift}${installRoot ? ` under ${win.join(installRoot, "Runtimes")}` : " (swift.exe is not under a Toolchains folder)"}`);
209
+
210
+ const exported = { variables: [], path: [] };
211
+ if (options.githubEnv) {
212
+ if (!env.GITHUB_ENV || !env.GITHUB_PATH) fail("TOOLCHAIN_GITHUB_ENV_MISSING", "--github-env needs GITHUB_ENV and GITHUB_PATH (run it inside a GitHub Actions step)");
213
+ const { changed, addedPath } = installed ? diffInstallerEnvironment(before, after) : { changed: {}, addedPath: [] };
214
+ const lines = Object.entries(changed).map(([name, value]) => `${name}=${value}`);
215
+ if (lines.length) fs.appendFileSync(env.GITHUB_ENV, `${lines.join("\n")}\n`);
216
+ exported.variables = Object.keys(changed);
217
+ exported.path = [...new Set([...addedPath, win.dirname(swift), runtimeBin])];
218
+ fs.appendFileSync(env.GITHUB_PATH, `${exported.path.join("\n")}\n`);
219
+ }
220
+ return { version: options.version, swift, runtimeBin, installed, exported };
221
+ }
222
+
223
+ export async function main(argv = process.argv.slice(2)) {
224
+ const opts = parseToolchainArgs(argv);
225
+ const result = await installSwiftToolchain(opts, { log: (line) => console.error(`right-release toolchain: ${line}`) });
226
+ if (opts.json) console.log(JSON.stringify(result, null, 2));
227
+ else {
228
+ console.log(`right-release toolchain: Swift ${result.version} ${result.installed ? "installed" : "already present"}; swift.exe ${result.swift}; runtime ${result.runtimeBin}`);
229
+ if (result.exported.variables.length || result.exported.path.length) console.log(`right-release toolchain: exported variables ${JSON.stringify(result.exported.variables)} and ${result.exported.path.length} PATH entr${result.exported.path.length === 1 ? "y" : "ies"}`);
230
+ }
231
+ return 0;
232
+ }
233
+
234
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
235
+ main().then((code) => { process.exitCode = code; }, (error) => {
236
+ console.error(`right-release toolchain: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
237
+ process.exitCode = 1;
238
+ });
239
+ }
@@ -36,7 +36,12 @@ if (platform === "android") {
36
36
  const { main } = await import("./android-play-upload.mjs");
37
37
  process.exit(await main({ releaseId, configName, apply, dryRun }));
38
38
  }
39
- if (apply) fail("--apply is Android only; win and mac preview with --dry-run");
39
+ // iOS: TestFlight through the App Store Connect build-upload API (in process, no key file), with its own gate and a dry run by default (ios-upload.mjs).
40
+ if (platform === "ios") {
41
+ const { main } = await import("./ios-upload.mjs");
42
+ process.exit(await main({ releaseId, configName, apply, dryRun }));
43
+ }
44
+ if (apply) fail("--apply is Android and iOS only; win and mac preview with --dry-run");
40
45
 
41
46
  // Tier, primary checkout, clean tree, sealed commit, ownership evidence, provider and repository are checked by
42
47
  // the gate that `right-release github --release` also uses; nothing below runs until it passes.
@@ -82,7 +87,7 @@ function writeJson(file, value) {
82
87
  }
83
88
 
84
89
  function usage(code) {
85
- console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.mjs] [--repo owner/repo] [--dry-run]\n right-release upload --platform android --release <sealed-id> [--config right-release.config.mjs] [--apply] (Play internal track; dry run unless --apply)");
90
+ console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.mjs] [--repo owner/repo] [--dry-run]\n right-release upload --platform android --release <sealed-id> [--config right-release.config.mjs] [--apply] (Play internal track; dry run unless --apply)\n right-release upload --platform ios --release <sealed-id> [--config right-release.config.mjs] [--apply] (TestFlight; dry run unless --apply)");
86
91
  process.exit(code);
87
92
  }
88
93
 
package/web-build.mjs ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // `right-release web-build [--config <file>] [--dry-run]` (also `right-release build --web`): the web frontend build
3
+ // alone, with no Tauri, signing or packaging. It runs the config's `web.build` steps in order, without a shell, from
4
+ // the app root, and stops at the first failure.
5
+ //
6
+ // web: {
7
+ // build: [
8
+ // { node: "node_modules/typescript/bin/tsc", nodeFlags: ["--disable-warning=DEP0205"] },
9
+ // { node: "node_modules/vite/bin/vite.js", args: ["build"], nodeFlags: ["--disable-warning=DEP0205"] },
10
+ // ],
11
+ // }
12
+ //
13
+ // A step is `{ cmd | node, args?, cwd?, env?, nodeFlags?, nodeOptions? }` (see command-spec.mjs). `--dry-run` prints the
14
+ // resolved commands and runs nothing. Exit code: 0, or the failing step's exit code.
15
+ import { pathToFileURL } from "node:url";
16
+ import { spawnSync } from "./background-process.mjs";
17
+ import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
18
+
19
+ export function parseWebBuildArgs(argv) {
20
+ const opts = { config: "right-release.config.mjs", dryRun: false };
21
+ for (let i = 0; i < argv.length; i += 1) {
22
+ const arg = argv[i];
23
+ if (arg === "--config") {
24
+ const value = argv[i + 1];
25
+ if (!value || value.startsWith("--")) throw new Error("--config needs a value");
26
+ opts.config = value;
27
+ i += 1;
28
+ } else if (arg === "--dry-run") opts.dryRun = true;
29
+ else throw new Error(`unknown web-build argument: ${arg}`);
30
+ }
31
+ return opts;
32
+ }
33
+
34
+ /** The validated `web.build` steps of a config. */
35
+ export function planWebBuild(config) {
36
+ const steps = config?.web?.build;
37
+ if (!Array.isArray(steps) || steps.length === 0) throw new CommandSpecError("the config declares no web.build steps");
38
+ const unknown = Object.keys(config.web).filter((key) => key !== "build");
39
+ if (unknown.length) throw new CommandSpecError(`web has unknown key(s): ${unknown.join(", ")} (allowed: build)`);
40
+ return steps.map((step, index) => validateStep(step, `web.build[${index}]`));
41
+ }
42
+
43
+ /** Run the steps in order; `spawnSyncFn` and `log` are injectable. Returns the exit code. */
44
+ export function runWebBuild(steps, { appRoot, env = process.env, dryRun = false, spawnSyncFn = spawnSync, log = console.log } = {}) {
45
+ for (const step of steps) {
46
+ const invocation = resolveInvocation(step, { appRoot, baseEnv: env });
47
+ log(`${dryRun ? "[dry-run] " : ""}${[invocation.cmd, ...invocation.args].join(" ")}`);
48
+ if (dryRun) continue;
49
+ const result = spawnSyncFn(invocation.cmd, invocation.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
50
+ if (result.error) throw result.error;
51
+ if (result.status !== 0) return result.status ?? 1;
52
+ }
53
+ return 0;
54
+ }
55
+
56
+ export async function main(argv = process.argv.slice(2)) {
57
+ const opts = parseWebBuildArgs(argv);
58
+ const { config, appRoot } = await loadAppConfig(opts.config);
59
+ return runWebBuild(planWebBuild(config), { appRoot, dryRun: opts.dryRun });
60
+ }
61
+
62
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
63
+ main().then((code) => { process.exitCode = code; }, (error) => {
64
+ console.error(`right-release web-build: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
65
+ process.exitCode = 1;
66
+ });
67
+ }