@rightkit/release 0.6.12 → 0.6.14
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/README.md +49 -1
- package/adapter-env.mjs +165 -0
- package/android-ndk.mjs +5 -1
- package/android-release.mjs +73 -8
- package/app-check.mjs +22 -8
- package/app-closure.mjs +214 -0
- package/assemble-app-command.mjs +24 -7
- package/atomic-write.mjs +57 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1190 -335
- package/broker-client.mjs +106 -0
- package/broker-exception.mjs +211 -0
- package/broker-handshake.mjs +160 -0
- package/broker-pins.mjs +27 -8
- package/broker-wire.mjs +262 -0
- package/build-launch.mjs +364 -12
- package/build-ownership.mjs +117 -49
- package/build-release.mjs +3 -2
- package/cache-command.mjs +7 -2
- package/candidate-build-receipt.mjs +96 -0
- package/candidate-receipt-command.mjs +29 -0
- package/cargo-contract.mjs +4 -3
- package/cargo-ledger.mjs +1 -1
- package/cargo-license-notices.mjs +2 -2
- package/cargo-shapes.mjs +332 -0
- package/cargo-target-shim.mjs +44 -5
- package/cargo-target.mjs +5 -0
- package/carried-adapter-run.mjs +31 -0
- package/carried-cargo-run.mjs +9 -0
- package/carried-cargo.mjs +237 -0
- package/carrier-census.mjs +124 -0
- package/class-a-launch-guard.mjs +266 -33
- package/cli/right-release.mjs +2 -0
- package/clippy-route.mjs +23 -11
- package/compile-inputs.mjs +9 -12
- package/control-plane.mjs +18 -0
- package/deps-cargo-lock.mjs +208 -0
- package/deps.mjs +5 -0
- package/deterministic-scans.mjs +273 -44
- package/development-signing.mjs +14 -1
- package/development.mjs +109 -24
- package/direct-origin.mjs +107 -0
- package/disk-command.mjs +1 -0
- package/disk-policy.mjs +18 -3
- package/freshness-census.mjs +206 -58
- package/gate-executor.mjs +30 -12
- package/gate-stages.mjs +93 -16
- package/github-release.mjs +2 -1
- package/github-upload-gate.mjs +2 -1
- package/install-tauri-authenticode.mjs +346 -0
- package/ios-dev.mjs +77 -23
- package/ios-package-recipe.mjs +100 -41
- package/linux-artifacts.mjs +17 -2
- package/linux-qualifier.mjs +83 -0
- package/local-publish.mjs +36 -14
- package/local-runtime.mjs +2 -2
- package/lock-identity.mjs +138 -0
- package/lsclean.sh +0 -0
- package/managed-cargo.mjs +151 -44
- package/managed-roots.mjs +130 -12
- package/media-sidecar-catalog.mjs +1 -1
- package/native-adapters.mjs +411 -0
- package/native-helpers.mjs +26 -18
- package/native-tool-planning.mjs +94 -0
- package/ownership-mark.mjs +182 -0
- package/pack-digest.mjs +122 -0
- package/package.json +12 -12
- package/platform-dir.mjs +14 -0
- package/pnpm-adapter-grammar.mjs +226 -0
- package/progress-control.mjs +7 -2
- package/provenance-real-receipt.pins.json +7 -0
- package/provenance-verify.mjs +262 -1
- package/publication-outcomes.mjs +21 -0
- package/publish-update.mjs +2 -1
- package/qa-journeys-prebuilt-run.mjs +18 -0
- package/qa-journeys-prebuilt.mjs +148 -0
- package/qa-snapshot-run.mjs +53 -0
- package/qa-snapshot.mjs +164 -0
- package/qualification-verify.mjs +144 -0
- package/qualify-reuse.mjs +38 -0
- package/recipe-build.mjs +16 -3
- package/release-qa-audit.mjs +2 -2
- package/release-qa-command.mjs +2 -1
- package/release-qa-receipt.mjs +3 -2
- package/release-state.mjs +3 -2
- package/release.mjs +14 -3
- package/rightkit-drift.mjs +27 -5
- package/rightkit-versions.json +15 -10
- package/rustright-names.mjs +32 -0
- package/scan-tool-enrolment.mjs +167 -0
- package/sign-windows.mjs +212 -112
- package/signing-files.mjs +35 -0
- package/signing-identity.mjs +7 -0
- package/snapshot-broker.mjs +528 -57
- package/snapshot-check.mjs +95 -11
- package/snapshot-metadata.mjs +123 -0
- package/snapshot-request.mjs +389 -96
- package/stage-binary.mjs +22 -4
- package/stage-commands.mjs +56 -13
- package/stage-sidecar.mjs +93 -21
- package/suite-signing-policy.mjs +657 -0
- package/suite-signing.mjs +223 -0
- package/swift-scratch.mjs +25 -0
- package/tauri-locked.mjs +25 -0
- package/test-command.mjs +164 -29
- package/unit-gate.mjs +201 -31
- package/upload-large.mjs +0 -0
- package/web-build.mjs +2 -2
- package/win-package-recipe.mjs +26 -2
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// `development.targets.win.install.kind: "tauri-authenticode"` (rightkit#1303): the Windows dev install that signs what it installs. The unsigned
|
|
2
|
+
// kind (`tauri`: build the NSIS installer and run it) is refused on the Dell, because a dev build must carry the product's one signing identity
|
|
3
|
+
// (Azure Artifact Signing, subject CN=Damned Ventures LLC) exactly as the release does. This kind reuses the release's order
|
|
4
|
+
// (package-recipe-targets.mjs winPackage, tauri-bundle-marker.mjs, nsis-payload.mjs, release.mjs) and its signer (sign-windows.mjs):
|
|
5
|
+
// 1. build the app without bundling (`target.build`: a `tauri build ... --no-bundle`; runDevelopment runs it before this module)
|
|
6
|
+
// 2. patch the bundle marker (UNK -> NSS BEFORE signing: Tauri rewrites it during bundling, and doing that to signed bytes breaks Authenticode)
|
|
7
|
+
// 3. sign the exe and its sidecars, then verify them (signer subject, timestamp)
|
|
8
|
+
// 4. carried `tauri bundle --bundles nsis --debug` (the installer embeds the exe as signed)
|
|
9
|
+
// 5. check the payload (the exe inside the installer is byte for byte the signed one)
|
|
10
|
+
// 6. sign the installer, then verify it
|
|
11
|
+
// 7. install (the silent installer, as the unsigned kind runs it)
|
|
12
|
+
// 8. verify (the installed exe equals the build, and its Authenticode subject is the signer's)
|
|
13
|
+
// Nothing installs before step 6 has verified, so no unsigned installer is ever run. Every host effect is a dependency, so the order runs with fakes.
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { spawnSync } from "./background-process.mjs";
|
|
18
|
+
import { neutralIgnored } from "./stage-ledger.mjs";
|
|
19
|
+
import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
|
|
20
|
+
import { listNsisPayload, verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
|
|
21
|
+
import { SIGN_SUBJECT, SuiteSigningError, WINDOWS_SIGNED_KIND, identifierOverrideReason, isProductBuildStep, stepWords } from "./suite-signing-policy.mjs";
|
|
22
|
+
|
|
23
|
+
export const TAURI_AUTHENTICODE_KIND = WINDOWS_SIGNED_KIND;
|
|
24
|
+
/** The steps in the order this module runs them (1 is runDevelopment's build). Exported so a test can prove the order. */
|
|
25
|
+
export const TAURI_AUTHENTICODE_ORDER = Object.freeze(["patch-marker", "sign-exe", "bundle", "check-payload", "sign-installer", "install", "verify"]);
|
|
26
|
+
const KEYS = new Set(["kind", "productName", "targetTriple", "manifestPath", "expectedInstalledPath", "sidecars", "tauriCwd"]);
|
|
27
|
+
|
|
28
|
+
export class TauriAuthenticodeError extends SuiteSigningError {
|
|
29
|
+
constructor(code, message, details) { super(code, message, details); this.name = "TauriAuthenticodeError"; }
|
|
30
|
+
}
|
|
31
|
+
const refuse = (code, message, details) => { throw new TauriAuthenticodeError(code, message, details); };
|
|
32
|
+
const sha256 = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
33
|
+
const relativeInside = (value, label) => {
|
|
34
|
+
const text = String(value ?? "").replaceAll("\\", "/");
|
|
35
|
+
if (!text || path.isAbsolute(text) || path.win32.isAbsolute(text) || /^[A-Za-z]:/.test(text) || text.split("/").includes("..")) refuse("TAURI_AUTHENTICODE_CONFIG", `${label} must be a relative path that stays inside its directory: ${JSON.stringify(value)}`);
|
|
36
|
+
return text;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Validate the descriptor and return it normalized. No other key is accepted: the identity is derived, so there is nothing to declare it with. */
|
|
40
|
+
export function resolveAuthenticodeDescriptor(descriptor, label = "development.targets.win.install") {
|
|
41
|
+
if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor)) refuse("TAURI_AUTHENTICODE_CONFIG", `${label} must be an object`);
|
|
42
|
+
const unknown = Object.keys(descriptor).filter((key) => !KEYS.has(key));
|
|
43
|
+
if (unknown.length) refuse("TAURI_AUTHENTICODE_CONFIG", `${label} has unknown key(s): ${unknown.join(", ")} (allowed: ${[...KEYS].join(", ")}). The signer is the product's one identity (${SIGN_SUBJECT}); it is never declared`);
|
|
44
|
+
if (descriptor.kind !== TAURI_AUTHENTICODE_KIND) refuse("TAURI_AUTHENTICODE_CONFIG", `${label}.kind must be ${TAURI_AUTHENTICODE_KIND}`);
|
|
45
|
+
for (const key of ["productName", "targetTriple", "expectedInstalledPath"]) if (typeof descriptor[key] !== "string" || !descriptor[key].trim()) refuse("TAURI_AUTHENTICODE_CONFIG", `${label}.${key} is required`);
|
|
46
|
+
if (!/-pc-windows-msvc$/.test(descriptor.targetTriple)) refuse("TAURI_AUTHENTICODE_CONFIG", `${label}.targetTriple must be a windows msvc triple, got ${descriptor.targetTriple}`);
|
|
47
|
+
if (typeof descriptor.manifestPath !== "string" || !descriptor.manifestPath.trim()) refuse("TAURI_AUTHENTICODE_CONFIG", `${label}.manifestPath is required (the Tauri manifest, to find the build output)`);
|
|
48
|
+
relativeInside(descriptor.manifestPath, `${label}.manifestPath`);
|
|
49
|
+
if (descriptor.tauriCwd !== undefined) relativeInside(descriptor.tauriCwd, `${label}.tauriCwd`);
|
|
50
|
+
const sidecars = descriptor.sidecars ?? [];
|
|
51
|
+
if (!Array.isArray(sidecars)) refuse("TAURI_AUTHENTICODE_CONFIG", `${label}.sidecars must be a list of paths relative to the build's output directory`);
|
|
52
|
+
return { ...descriptor, sidecars: sidecars.map((item, index) => relativeInside(item, `${label}.sidecars[${index}]`)) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The build must not bundle: a `tauri build` that also bundles would embed the unsigned exe in the installer. Every Tauri build of the target needs
|
|
57
|
+
* `--no-bundle` and none names `--bundles`; a `--config` that sets the identifier is refused. `readFile(rel)` reads an app-relative file (text or null).
|
|
58
|
+
*/
|
|
59
|
+
export function validateAuthenticodeBuild(build, label = "development.targets.win.build", readFile = () => null) {
|
|
60
|
+
let builds = 0;
|
|
61
|
+
(Array.isArray(build) ? build : []).forEach((step, index) => {
|
|
62
|
+
if (!isProductBuildStep(step)) return;
|
|
63
|
+
const words = stepWords(step);
|
|
64
|
+
const at = `${label}[${index}]`;
|
|
65
|
+
const tauriBuild = words.some((word) => word === "tauri") && words.includes("build");
|
|
66
|
+
if (words.includes("bundle")) refuse("TAURI_AUTHENTICODE_BUILD", `${at} runs \`tauri bundle\`; the bundle is this install kind's own step 4, after the exe is signed`);
|
|
67
|
+
if (tauriBuild) {
|
|
68
|
+
builds += 1;
|
|
69
|
+
if (!words.includes("--no-bundle") || words.includes("--bundles") || words.some((word) => word.startsWith("--bundles="))) refuse("TAURI_AUTHENTICODE_BUILD", `${at} must be \`tauri build --no-bundle\` (and name no --bundles): bundling here would embed the unsigned exe in the installer`);
|
|
70
|
+
}
|
|
71
|
+
const override = identifierOverrideReason(step, readFile);
|
|
72
|
+
if (override) refuse("TAURI_AUTHENTICODE_BUILD", `${at}: ${override}`);
|
|
73
|
+
});
|
|
74
|
+
if (!builds) refuse("TAURI_AUTHENTICODE_BUILD", `${label} has no \`tauri build --no-bundle\` step: this install kind signs the raw exe the build leaves in its output directory`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The carried bundle command (step 4): the same Tauri bundle the release makes, debug profile, no Tauri-side signing (the installer is signed by step 6). */
|
|
78
|
+
export function authenticodeBundleCommand(descriptor) {
|
|
79
|
+
const override = { bundle: { createUpdaterArtifacts: false, windows: { signCommand: null } } };
|
|
80
|
+
return { cmd: "pnpm", args: ["exec", "tauri", "bundle", "--bundles", "nsis", "--debug", "--target", descriptor.targetTriple, "--config", JSON.stringify(override)], ...(descriptor.tauriCwd ? { cwd: descriptor.tauriCwd } : {}), name: "tauri bundle" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const exeBase = (name) => String(name).replace(/\.exe$/i, "");
|
|
84
|
+
const readJson = (readFile, file) => { const text = readFile(file); if (text === null || text === undefined) return null; try { return JSON.parse(text); } catch (error) { return refuse("TAURI_AUTHENTICODE_CONFIG", `${file} is not valid JSON: ${error.message}`); } };
|
|
85
|
+
const readText = (file) => (existsSync(file) ? readFileSync(file, "utf8") : null);
|
|
86
|
+
const isFile = (file) => existsSync(file) && statSync(file).isFile() && statSync(file).size > 0;
|
|
87
|
+
|
|
88
|
+
/** The `tauri.conf.json` directory: the Tauri manifest's directory under `appRoot`, else the parent of the build's `target/` ancestor. */
|
|
89
|
+
function resolveTauriDir({ descriptor, debugDir, appRoot }) {
|
|
90
|
+
if (appRoot) return path.dirname(path.resolve(appRoot, descriptor.manifestPath));
|
|
91
|
+
let dir = path.resolve(debugDir);
|
|
92
|
+
while (path.dirname(dir) !== dir) { if (path.basename(dir) === "target") return path.dirname(dir); dir = path.dirname(dir); }
|
|
93
|
+
return refuse("TAURI_AUTHENTICODE_CONFIG", `cannot find the Tauri directory from ${debugDir}: pass appRoot`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The sidecar names the release's config.sidecars (right-release.config.mjs `sidecars`) stages as Tauri externalBin for Windows. */
|
|
97
|
+
export function configSidecarNames(releaseConfig) {
|
|
98
|
+
const raw = releaseConfig?.sidecars;
|
|
99
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.sidecars) ? raw.sidecars : [];
|
|
100
|
+
const names = [];
|
|
101
|
+
for (const item of list) {
|
|
102
|
+
if (!item || typeof item !== "object" || !["cargo-bin", "command", "remote", "cmake", "prebuilt"].includes(item.kind)) continue;
|
|
103
|
+
if (Array.isArray(item.platforms) && !item.platforms.includes("win")) continue;
|
|
104
|
+
if (item.kind === "command" && item.externalBin === false) continue;
|
|
105
|
+
const name = item.name ?? item.bin ?? item.package;
|
|
106
|
+
if (typeof name === "string" && name) names.push(exeBase(name));
|
|
107
|
+
}
|
|
108
|
+
return names;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The required sidecar set, derived from `bundle.externalBin` (tauri.windows.conf.json replaces tauri.conf.json's list, as Tauri merges it) and
|
|
113
|
+
* `config.sidecars`. A declared `install.sidecars` list must EQUAL the derived set, refused in either direction; with no list the derived set stands.
|
|
114
|
+
* Each sidecar has up to two copies: the build output (`debugDir/<name>.exe`, tauri-build's copy) and the bundler source
|
|
115
|
+
* (`<tauriDir>/<externalBin>-<triple>.exe`, which `tauri bundle` reads). Both are signed; the payload must match one of them.
|
|
116
|
+
*/
|
|
117
|
+
export function deriveRequiredSidecars({ descriptor, debugDir, tauriDir, releaseConfig, readFile = readText }) {
|
|
118
|
+
const derived = new Map();
|
|
119
|
+
const add = (name, { bundlerSource, buildOutput }) => {
|
|
120
|
+
const key = name.toLowerCase();
|
|
121
|
+
const entry = derived.get(key) ?? { name, file: `${name}.exe`, candidates: [] };
|
|
122
|
+
for (const [role, file] of [["build-output", buildOutput], ["bundler-source", bundlerSource]]) if (file && !entry.candidates.some((c) => c.path === file)) entry.candidates.push({ role, path: file });
|
|
123
|
+
derived.set(key, entry);
|
|
124
|
+
};
|
|
125
|
+
const base = readJson(readFile, path.join(tauriDir, "tauri.conf.json"));
|
|
126
|
+
const windows = readJson(readFile, path.join(tauriDir, "tauri.windows.conf.json"));
|
|
127
|
+
if (!base && !windows) refuse("TAURI_AUTHENTICODE_CONFIG", `no tauri.conf.json under ${tauriDir}: the sidecar set cannot be derived`);
|
|
128
|
+
const externalBin = windows?.bundle?.externalBin ?? base?.bundle?.externalBin ?? [];
|
|
129
|
+
if (!Array.isArray(externalBin)) refuse("TAURI_AUTHENTICODE_CONFIG", "bundle.externalBin must be a list");
|
|
130
|
+
for (const item of externalBin) {
|
|
131
|
+
const rel = relativeInside(item, "bundle.externalBin entry");
|
|
132
|
+
const name = exeBase(path.posix.basename(rel));
|
|
133
|
+
add(name, { bundlerSource: path.join(tauriDir, ...rel.split("/").slice(0, -1), `${name}-${descriptor.targetTriple}.exe`), buildOutput: path.join(debugDir, `${name}.exe`) });
|
|
134
|
+
}
|
|
135
|
+
for (const name of configSidecarNames(releaseConfig)) add(name, { bundlerSource: path.join(tauriDir, "binaries", `${name}-${descriptor.targetTriple}.exe`), buildOutput: path.join(debugDir, `${name}.exe`) });
|
|
136
|
+
if (descriptor.sidecars.length) {
|
|
137
|
+
const listed = new Map(descriptor.sidecars.map((item) => [exeBase(path.posix.basename(item)).toLowerCase(), item]));
|
|
138
|
+
for (const [key, entry] of derived) if (!listed.has(key)) refuse("TAURI_AUTHENTICODE_SIDECAR_UNLISTED", `sidecar ${entry.name} is derived from bundle.externalBin / config.sidecars but install.sidecars leaves it out`, { sidecar: entry.name });
|
|
139
|
+
for (const [key, item] of listed) if (!derived.has(key)) refuse("TAURI_AUTHENTICODE_SIDECAR_UNDERIVED", `install.sidecars lists ${item}, which neither bundle.externalBin nor config.sidecars declares`, { sidecar: item });
|
|
140
|
+
for (const [key, item] of listed) add(derived.get(key).name, { buildOutput: path.resolve(debugDir, item) });
|
|
141
|
+
}
|
|
142
|
+
const sidecars = [...derived.values()];
|
|
143
|
+
for (const sidecar of sidecars) {
|
|
144
|
+
sidecar.copies = sidecar.candidates.filter((c) => isFile(c.path));
|
|
145
|
+
if (!sidecar.copies.length) refuse("TAURI_AUTHENTICODE_SIDECAR_MISSING", `sidecar ${sidecar.name} is in neither the build output nor the bundler source (${sidecar.candidates.map((c) => c.path).join(", ")})`, { sidecar: sidecar.name });
|
|
146
|
+
}
|
|
147
|
+
return sidecars;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
151
|
+
const mergePatch = (base, over) => {
|
|
152
|
+
if (over === undefined) return base;
|
|
153
|
+
if (isPlainObject(base) && isPlainObject(over)) {
|
|
154
|
+
const out = { ...base };
|
|
155
|
+
for (const [key, value] of Object.entries(over)) { if (value === null) delete out[key]; else out[key] = mergePatch(base[key], value); }
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
return over;
|
|
159
|
+
};
|
|
160
|
+
const globRegex = (segment) => new RegExp(`^${segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`, "i");
|
|
161
|
+
const collectExecutables = (dir, rel = "") => {
|
|
162
|
+
const found = [];
|
|
163
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
164
|
+
const next = rel ? `${rel}/${entry.name}` : entry.name;
|
|
165
|
+
if (entry.isDirectory()) found.push(...collectExecutables(path.join(dir, entry.name), next));
|
|
166
|
+
else if (/\.exe$/i.test(entry.name)) found.push({ abs: path.join(dir, entry.name), rel: next });
|
|
167
|
+
}
|
|
168
|
+
return found;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Every `.exe` the installer ships as a resource, from `bundle.resources` (base and windows config merged as Tauri does): a list of paths, or a map
|
|
173
|
+
* of source to destination. Sources may be a file, a directory (searched recursively) or a glob (`*`, `?`, `**` segments). Only existing files count.
|
|
174
|
+
* Release signs every executable the installer wraps (win-package-recipe.mjs sign-raw), so a resource executable carries the same identity here.
|
|
175
|
+
* Each returned entry is `{ name, file, installRel, copies: [{ role: "resource", path }] }`; `installRel` is its path under the install directory.
|
|
176
|
+
*/
|
|
177
|
+
export function deriveResourceExecutables({ tauriDir, readFile = readText }) {
|
|
178
|
+
const base = readJson(readFile, path.join(tauriDir, "tauri.conf.json"));
|
|
179
|
+
const windows = readJson(readFile, path.join(tauriDir, "tauri.windows.conf.json"));
|
|
180
|
+
const resources = mergePatch(base?.bundle?.resources, windows?.bundle?.resources);
|
|
181
|
+
if (resources === undefined || resources === null) return [];
|
|
182
|
+
const pairs = Array.isArray(resources)
|
|
183
|
+
? resources.map((src) => [src, null])
|
|
184
|
+
: isPlainObject(resources) ? Object.entries(resources) : refuse("TAURI_AUTHENTICODE_CONFIG", "bundle.resources must be a list or a map");
|
|
185
|
+
const found = new Map();
|
|
186
|
+
for (const [src, destValue] of pairs) {
|
|
187
|
+
const source = String(src).replaceAll("\\", "/").replace(/^\.\//, "");
|
|
188
|
+
const segments = source.split("/");
|
|
189
|
+
const wild = segments.findIndex((segment) => /[*?]/.test(segment));
|
|
190
|
+
const prefixSegments = wild === -1 ? segments : segments.slice(0, wild);
|
|
191
|
+
const prefix = path.resolve(tauriDir, ...prefixSegments);
|
|
192
|
+
const hits = [];
|
|
193
|
+
const walk = (dir, rest, rel) => {
|
|
194
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return;
|
|
195
|
+
const [head, ...tail] = rest;
|
|
196
|
+
if (head === undefined) { for (const item of collectExecutables(dir)) hits.push({ abs: item.abs, rel: rel ? `${rel}/${item.rel}` : item.rel }); return; }
|
|
197
|
+
if (head === "**") { walk(dir, tail, rel); for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isDirectory()) walk(path.join(dir, entry.name), rest, rel ? `${rel}/${entry.name}` : entry.name); return; }
|
|
198
|
+
const matcher = globRegex(head);
|
|
199
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
200
|
+
if (!matcher.test(entry.name)) continue;
|
|
201
|
+
const next = rel ? `${rel}/${entry.name}` : entry.name;
|
|
202
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name), tail.length ? tail : [], next);
|
|
203
|
+
else if (tail.length === 0 && /\.exe$/i.test(entry.name)) hits.push({ abs: path.join(dir, entry.name), rel: next });
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
if (wild !== -1) walk(prefix, segments.slice(wild), "");
|
|
207
|
+
else if (existsSync(prefix) && statSync(prefix).isDirectory()) walk(prefix, [], "");
|
|
208
|
+
else if (/\.exe$/i.test(prefix) && isFile(prefix)) hits.push({ abs: prefix, rel: path.posix.basename(source) });
|
|
209
|
+
const isDir = wild !== -1 || (existsSync(prefix) && statSync(prefix).isDirectory());
|
|
210
|
+
for (const hit of hits) {
|
|
211
|
+
let dest;
|
|
212
|
+
if (destValue === null || destValue === undefined) dest = source.replaceAll("../", "_up_/");
|
|
213
|
+
else if (isDir || String(destValue).endsWith("/")) dest = `${String(destValue).replace(/\/+$/, "")}${destValue === "" ? "" : "/"}${isDir ? hit.rel : path.posix.basename(source)}`;
|
|
214
|
+
else dest = String(destValue);
|
|
215
|
+
dest = dest.replaceAll("\\", "/").replace(/^\.?\//, "");
|
|
216
|
+
if (destValue === null || destValue === undefined) dest = isDir ? `${dest.replace(/\/+$/, "")}/${hit.rel}`.replace(/^\/+/, "") : dest;
|
|
217
|
+
if (!isFile(hit.abs) || found.has(hit.abs.toLowerCase())) continue;
|
|
218
|
+
found.set(hit.abs.toLowerCase(), { name: dest, file: path.posix.basename(dest), installRel: dest, kind: "resource", copies: [{ role: "resource", path: hit.abs }] });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return [...found.values()];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `Map(file -> "tracked" | "ignored" | "unignored")` through git: `ls-files --error-unmatch` for tracked, then the commit's own ignore rules (stage-ledger.mjs neutralIgnored). Throws when git cannot answer. */
|
|
225
|
+
export function classifyStagedFiles(files, { appRoot, tauriDir }) {
|
|
226
|
+
const out = new Map();
|
|
227
|
+
for (const file of files) {
|
|
228
|
+
const dir = path.dirname(path.resolve(file));
|
|
229
|
+
const top = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true });
|
|
230
|
+
if (top.status !== 0) refuse("TAURI_AUTHENTICODE_TRACKED_RESOURCE", `cannot tell whether ${file} is tracked: git rev-parse failed under ${appRoot ?? tauriDir}`, { file });
|
|
231
|
+
const root = String(top.stdout).trim();
|
|
232
|
+
const rel = path.relative(root, path.resolve(file)).split(path.sep).join("/");
|
|
233
|
+
const tracked = spawnSync("git", ["-C", root, "ls-files", "--error-unmatch", "--", rel], { encoding: "utf8", windowsHide: true });
|
|
234
|
+
if (tracked.status === 0) { out.set(file, "tracked"); continue; }
|
|
235
|
+
if (tracked.status !== 1) refuse("TAURI_AUTHENTICODE_TRACKED_RESOURCE", `cannot tell whether ${file} is tracked: git ls-files exited ${tracked.status}`, { file });
|
|
236
|
+
out.set(file, neutralIgnored({ dir: root, paths: [rel] }).has(rel) ? "ignored" : "unignored");
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Steps 2 to 8 (step 1, the build, has run). `debugDir` is the build's output directory holding the raw exe. `appRoot` (to find the Tauri directory;
|
|
243
|
+
* else derived from `debugDir`'s `target/` ancestor) and `releaseConfig` (for config.sidecars) feed the sidecar derivation. deps:
|
|
244
|
+
* patchMarker(file) signWindows(files) -> evidence[] verifyWindows(files) -> evidence[] bundle(command) findInstaller() -> path
|
|
245
|
+
* listPayload(installer) -> [{ name }] verifyPayload({ installer, entryName, expectedSha256 }) install(installer) installedExe() -> path
|
|
246
|
+
* classifyStaged(files) -> Map(file -> tracked|ignored|unignored) installedSidecar(file) -> path (default: next to the installed exe) readFile(path) -> text | null
|
|
247
|
+
* Returns `{ order, exe, installer, installed, payload, sidecars, signing }`; `signing` records the sha256 of each signed file before and after.
|
|
248
|
+
*/
|
|
249
|
+
export function installTauriAuthenticode({ descriptor: raw, debugDir, appRoot, releaseConfig, deps = {} }) {
|
|
250
|
+
const descriptor = resolveAuthenticodeDescriptor(raw);
|
|
251
|
+
const order = [];
|
|
252
|
+
const step = (name, run) => { order.push(name); return run(); };
|
|
253
|
+
const exe = path.join(debugDir, path.basename(descriptor.expectedInstalledPath));
|
|
254
|
+
if (!existsSync(exe) || !statSync(exe).isFile() || statSync(exe).size === 0) refuse("TAURI_AUTHENTICODE_BUILD_MISSING", `the build left no ${path.basename(exe)} in ${debugDir}`);
|
|
255
|
+
const tauriDir = resolveTauriDir({ descriptor, debugDir, appRoot });
|
|
256
|
+
const sidecars = [...deriveRequiredSidecars({ descriptor, debugDir, tauriDir, releaseConfig, readFile: deps.readFile }), ...deriveResourceExecutables({ tauriDir, readFile: deps.readFile })];
|
|
257
|
+
const patchMarker = deps.patchMarker ?? ((file) => patchTauriBundleType(file, { bundle: "nsis" }));
|
|
258
|
+
const verifyPayload = deps.verifyPayload ?? verifyNsisEmbeddedBinary;
|
|
259
|
+
const listPayload = deps.listPayload ?? ((installer) => listNsisPayload(installer));
|
|
260
|
+
const sidecarNames = new Set(sidecars.map((sidecar) => sidecar.file.toLowerCase()));
|
|
261
|
+
const recorded = (evidence, files) => {
|
|
262
|
+
if (!Array.isArray(evidence) || evidence.length !== files.length) refuse("TAURI_AUTHENTICODE_SIGN", `the signer returned ${Array.isArray(evidence) ? evidence.length : "no"} record(s) for ${files.length} file(s)`);
|
|
263
|
+
evidence.forEach((item) => {
|
|
264
|
+
if (item.subject === SIGN_SUBJECT && item.authenticode === "Valid") return;
|
|
265
|
+
const code = sidecarNames.has(path.basename(String(item.file)).toLowerCase()) ? "TAURI_AUTHENTICODE_SIDECAR_UNSIGNED" : "TAURI_AUTHENTICODE_SIGN";
|
|
266
|
+
refuse(code, `${item.file}: signer subject ${JSON.stringify(item.subject)} / ${JSON.stringify(item.authenticode)}, expected ${SIGN_SUBJECT} / Valid`);
|
|
267
|
+
});
|
|
268
|
+
return evidence;
|
|
269
|
+
};
|
|
270
|
+
// Signing a copy in place rewrites it. A copy outside the build's output directory (src-tauri/binaries, src-tauri/resources) must be a git-ignored stage output
|
|
271
|
+
// (the gate's stage-output ledger records it); a TRACKED one would change the commit's tree, so it is refused before anything is signed.
|
|
272
|
+
const inPlace = sidecars.flatMap((sidecar) => sidecar.copies.map((copy) => copy.path)).filter((file) => path.relative(path.resolve(debugDir), path.resolve(file)).startsWith(".."));
|
|
273
|
+
const classify = deps.classifyStaged ?? classifyStagedFiles;
|
|
274
|
+
const classes = inPlace.length ? classify(inPlace, { appRoot, tauriDir }) : new Map();
|
|
275
|
+
for (const file of inPlace) {
|
|
276
|
+
const kind = classes.get(file) ?? classes.get(path.resolve(file));
|
|
277
|
+
if (kind === "tracked") refuse("TAURI_AUTHENTICODE_TRACKED_RESOURCE", `${file} is tracked by git, and signing it in place would change the commit's tree: stage it (or git-ignore it) the way release stages it`, { file });
|
|
278
|
+
if (kind !== "ignored") refuse("TAURI_AUTHENTICODE_TRACKED_RESOURCE", `${file} is not a git-ignored stage output (${kind ?? "unknown"}): it cannot be signed in place`, { file, state: kind ?? "unknown" });
|
|
279
|
+
}
|
|
280
|
+
// 2. The marker reaches its final value before any byte is signed.
|
|
281
|
+
step("patch-marker", () => patchMarker(exe));
|
|
282
|
+
// 3. Sign and verify the exe and every copy of every sidecar, and record each digest.
|
|
283
|
+
const signedFiles = [exe, ...sidecars.flatMap((sidecar) => sidecar.copies.map((copy) => copy.path))];
|
|
284
|
+
const signedExe = step("sign-exe", () => {
|
|
285
|
+
const signed = recorded(deps.signWindows(signedFiles), signedFiles);
|
|
286
|
+
recorded(deps.verifyWindows(signedFiles), signedFiles);
|
|
287
|
+
return signed;
|
|
288
|
+
});
|
|
289
|
+
const signedSha = sha256(exe);
|
|
290
|
+
for (const sidecar of sidecars) {
|
|
291
|
+
sidecar.digests = new Set();
|
|
292
|
+
for (const copy of sidecar.copies) {
|
|
293
|
+
const record = signedExe.find((item) => path.resolve(item.file) === path.resolve(copy.path));
|
|
294
|
+
copy.sha256After = record?.after?.sha256;
|
|
295
|
+
if (!copy.sha256After || copy.sha256After !== sha256(copy.path)) refuse("TAURI_AUTHENTICODE_SIDECAR_STALE", `${copy.path}: the signer recorded sha256After ${copy.sha256After}, the file is ${sha256(copy.path)}`, { sidecar: sidecar.name });
|
|
296
|
+
sidecar.digests.add(copy.sha256After);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// 4. The carried bundle; a signed copy that changed since signing is stale.
|
|
300
|
+
step("bundle", () => deps.bundle(authenticodeBundleCommand(descriptor)));
|
|
301
|
+
for (const sidecar of sidecars) for (const copy of sidecar.copies) if (!isFile(copy.path) || sha256(copy.path) !== copy.sha256After) refuse("TAURI_AUTHENTICODE_SIDECAR_STALE", `${copy.path} changed after it was signed (signed ${copy.sha256After})`, { sidecar: sidecar.name });
|
|
302
|
+
const installer = deps.findInstaller();
|
|
303
|
+
// 5. The installer must embed the signed exe and each signed sidecar (the bundler's copy or the build output's), byte for byte, and no other executable.
|
|
304
|
+
const payload = step("check-payload", () => {
|
|
305
|
+
const main = verifyPayload({ installer, entryName: path.basename(exe), expectedSha256: signedSha });
|
|
306
|
+
const embedded = sidecars.map((sidecar) => {
|
|
307
|
+
const failures = [];
|
|
308
|
+
for (const digest of sidecar.digests) {
|
|
309
|
+
try { return { name: sidecar.name, ...verifyPayload({ installer, entryName: sidecar.file, expectedSha256: digest }) }; } catch (error) { failures.push(error.message); }
|
|
310
|
+
}
|
|
311
|
+
return refuse(failures.every((message) => /does not embed/.test(message)) ? "TAURI_AUTHENTICODE_SIDECAR_STALE" : "TAURI_AUTHENTICODE_PAYLOAD", `the installer does not carry the signed ${sidecar.file}: ${failures.join("; ")}`, { sidecar: sidecar.name });
|
|
312
|
+
});
|
|
313
|
+
const known = new Set([path.basename(exe).toLowerCase(), ...sidecarNames]);
|
|
314
|
+
for (const entry of listPayload(installer)) {
|
|
315
|
+
const name = String(entry.name).replaceAll("\\", "/");
|
|
316
|
+
const leaf = path.posix.basename(name).toLowerCase();
|
|
317
|
+
if (!leaf.endsWith(".exe") || name.startsWith("$PLUGINSDIR") || leaf === "uninstall.exe" || known.has(leaf)) continue;
|
|
318
|
+
refuse("TAURI_AUTHENTICODE_PAYLOAD_UNSIGNED_EXECUTABLE", `the installer carries an executable that is not signed here: ${entry.name}`, { entry: entry.name });
|
|
319
|
+
}
|
|
320
|
+
return { ...main, sidecars: embedded };
|
|
321
|
+
});
|
|
322
|
+
// 6. Sign and verify the installer.
|
|
323
|
+
const signedInstaller = step("sign-installer", () => {
|
|
324
|
+
const signed = recorded(deps.signWindows([installer]), [installer]);
|
|
325
|
+
recorded(deps.verifyWindows([installer]), [installer]);
|
|
326
|
+
return signed;
|
|
327
|
+
});
|
|
328
|
+
// 7. Install (only now), then 8. verify what was installed: the exe and each sidecar, by signature and by digest.
|
|
329
|
+
step("install", () => deps.install(installer));
|
|
330
|
+
const installed = deps.installedExe();
|
|
331
|
+
const installedSidecars = sidecars.map((sidecar) => ({ sidecar, path: deps.installedSidecar ? deps.installedSidecar(sidecar.file, sidecar) : path.join(path.dirname(installed), sidecar.installRel ?? sidecar.file) }));
|
|
332
|
+
for (const item of installedSidecars) if (!isFile(item.path)) refuse("TAURI_AUTHENTICODE_SIDECAR_MISSING", `sidecar ${item.sidecar.name} is not installed at ${item.path}`, { sidecar: item.sidecar.name });
|
|
333
|
+
const installedFiles = [installed, ...installedSidecars.map((item) => item.path)];
|
|
334
|
+
step("verify", () => recorded(deps.verifyWindows(installedFiles), installedFiles));
|
|
335
|
+
for (const item of installedSidecars) {
|
|
336
|
+
item.sha256 = sha256(item.path);
|
|
337
|
+
if (!item.sidecar.digests.has(item.sha256)) refuse("TAURI_AUTHENTICODE_SIDECAR_STALE", `installed ${item.path} (sha256 ${item.sha256}) is not the signed ${item.sidecar.file}`, { sidecar: item.sidecar.name });
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
order, exe, installer, installed, payload,
|
|
341
|
+
// In-place signed stage outputs (git-ignored): the gate's ledger walk attributes them from `files` (stage-ledger.mjs reportedPaths); paths are absolute here, the caller makes them app-relative.
|
|
342
|
+
stageOutputs: { files: inPlace.map((file) => ({ path: file.replaceAll("\\", "/"), kind: "signed-in-place", sha256: sha256(file) })) },
|
|
343
|
+
sidecars: installedSidecars.map((item) => ({ name: item.sidecar.name, copies: item.sidecar.copies.map((copy) => ({ role: copy.role, path: copy.path, sha256After: copy.sha256After })), installed: { path: item.path, sha256: item.sha256 } })),
|
|
344
|
+
signing: { subject: SIGN_SUBJECT, files: [...signedExe, ...signedInstaller].map((item) => ({ file: item.file, sha256Before: item.before?.sha256, sha256After: item.after?.sha256, subject: item.subject })) },
|
|
345
|
+
};
|
|
346
|
+
}
|
package/ios-dev.mjs
CHANGED
|
@@ -13,13 +13,19 @@
|
|
|
13
13
|
// | { kind: "device", device: "<devicectl identifier>", bundleId, productName?, launch?: true, launchArgs?: [] },
|
|
14
14
|
// }
|
|
15
15
|
// The build commands are generated from the config (a hand-written `build` is refused). The device is chosen by id only, never
|
|
16
|
-
// "the only device": `install.device`, `--serial` or RIGHTKIT_IOS_DEVICE.
|
|
16
|
+
// "the only device": `install.device`, `--serial` or RIGHTKIT_IOS_DEVICE.
|
|
17
|
+
//
|
|
18
|
+
// RA-09 / R3-M02: xcodegen and every xcodebuild call (build, build-for-testing, test-without-building) are typed native adapter jobs of the production runner (build-launch.mjs createNativeRunner,
|
|
19
|
+
// `runIosDevelopmentCommands`): no -derivedDataPath, -resultBundlePath, -archivePath or -exportPath is ever named (the broker owns the output root), and the built .app and the result bundle are READ
|
|
20
|
+
// BACK from the verified receipt's managed output roles (`derived-data`, `result-bundle`). A call the door or the broker's grammar refuses (no runner route, an unadvertised capability, an argv with
|
|
21
|
+
// no managed form) is refused before anything is launched; there is no spawn fallback. simctl, devicectl and the app's own non-native `prepare` commands stay direct, non-compiling steps.
|
|
17
22
|
import * as nodeFs from "node:fs";
|
|
18
23
|
import os from "node:os";
|
|
19
24
|
import path from "node:path";
|
|
20
25
|
import { spawnSync } from "./background-process.mjs";
|
|
21
|
-
import { assertGuiSigningSession, IosHostError, outputTail
|
|
26
|
+
import { assertGuiSigningSession, IosHostError, outputTail } from "./ios-host.mjs";
|
|
22
27
|
import { SECRET_VALUE } from "./ios-package-recipe.mjs";
|
|
28
|
+
import { NATIVE_STATE_LEAVES, nativeStateDir, nativeToolOf } from "./native-adapters.mjs";
|
|
23
29
|
|
|
24
30
|
export class IosDevelopmentError extends Error {
|
|
25
31
|
constructor(code, message) { super(message); this.name = "IosDevelopmentError"; this.code = code; }
|
|
@@ -117,14 +123,14 @@ export function validateIosDevelopment(target, { root, env = process.env } = {})
|
|
|
117
123
|
uiTests = { scheme: t.scheme ?? target.scheme, ...(t.testPlan ? { testPlan: t.testPlan } : {}), onlyTesting: [...t.onlyTesting] };
|
|
118
124
|
}
|
|
119
125
|
const normalized = { project, projectSpec, scheme: target.scheme, configuration, teamId: target.teamId, extraBuildSettings, prepare, install, uiTests };
|
|
120
|
-
return { ...normalized, build: iosDevelopmentBuildCommands(normalized, { root,
|
|
126
|
+
return { ...normalized, build: iosDevelopmentBuildCommands(normalized, { root, destinationId: "<device or simulator id>" }) };
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
const destinationFor = (install, id) => (install?.kind === "device" ? `id=${id}` : `platform=iOS Simulator,id=${id}`);
|
|
124
130
|
export const productsDirectory = (dev) => `${dev.configuration}-${dev.install?.kind === "device" ? "iphoneos" : "iphonesimulator"}`;
|
|
125
131
|
|
|
126
|
-
/** Pure: the generated argv commands, in order. No secret is ever part of them. */
|
|
127
|
-
export function iosDevelopmentBuildCommands(dev, { root,
|
|
132
|
+
/** Pure: the generated argv commands, in order. No secret is ever part of them, and none names where a build writes (the broker's xcodebuild adapter owns the output root). */
|
|
133
|
+
export function iosDevelopmentBuildCommands(dev, { root, destinationId }) {
|
|
128
134
|
const commands = [];
|
|
129
135
|
if (dev.projectSpec) commands.push({ cmd: "xcodegen", args: ["generate", "--spec", path.resolve(root, dev.projectSpec), "--project", path.resolve(root, path.posix.dirname(dev.project))], name: "xcodegen generate" });
|
|
130
136
|
for (const step of dev.prepare) commands.push({ ...step, name: step.cmd });
|
|
@@ -137,8 +143,7 @@ export function iosDevelopmentBuildCommands(dev, { root, storage, destinationId
|
|
|
137
143
|
"-scheme", dev.scheme,
|
|
138
144
|
"-configuration", dev.configuration,
|
|
139
145
|
"-destination", dev.install ? destinationFor(dev.install, destinationId) : "generic/platform=iOS Simulator",
|
|
140
|
-
|
|
141
|
-
...(storage.resultBundle ? ["-resultBundlePath", storage.resultBundle] : []),
|
|
146
|
+
// GB-51 / RA-09: the xcodebuild ADAPTER owns where the build writes (the one managed output root, named by the signed receipt): the step names no -derivedDataPath or -resultBundlePath.
|
|
142
147
|
...(dev.teamId ? [`DEVELOPMENT_TEAM=${dev.teamId}`] : []),
|
|
143
148
|
...Object.entries(dev.extraBuildSettings).map(([name, value]) => `${name}=${value}`),
|
|
144
149
|
...(device ? ["-allowProvisioningUpdates", "-allowProvisioningDeviceRegistration"] : []),
|
|
@@ -215,11 +220,11 @@ function listDevices({ run, fs, tmpRoot }) {
|
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
/**
|
|
218
|
-
* Resolve the
|
|
219
|
-
*
|
|
223
|
+
* Resolve the device or simulator and the final build commands BEFORE the (long) build, so a missing or unready device is refused up front.
|
|
224
|
+
* Returns { build, brokerOwned, install } with install carrying the resolved id. The built .app is known only after the build, from the verified
|
|
225
|
+
* receipt's managed `derived-data` role (`iosDevelopmentApp`, or `appFromOutputDir` for a caller that holds the job's output directory).
|
|
220
226
|
*/
|
|
221
227
|
export function prepareIosDevelopment(dev, { root, env = process.env, run = spawnSync, fs = nodeFs, serial, tmpRoot = os.tmpdir() }) {
|
|
222
|
-
const storage = resolveXcodeStorage({ env, run, cwd: root, fallbackRoot: path.join(root, ".right-release", "ios-dev") });
|
|
223
228
|
let resolved = null;
|
|
224
229
|
if (dev.install) {
|
|
225
230
|
const wanted = iosDevelopmentDevice({ descriptor: dev.install, serial, env });
|
|
@@ -231,9 +236,21 @@ export function prepareIosDevelopment(dev, { root, env = process.env, run = spaw
|
|
|
231
236
|
resolved = { id: device.identifier, detail: device.detail };
|
|
232
237
|
}
|
|
233
238
|
}
|
|
234
|
-
const build = iosDevelopmentBuildCommands(dev, { root,
|
|
235
|
-
const
|
|
236
|
-
|
|
239
|
+
const build = iosDevelopmentBuildCommands(dev, { root, destinationId: resolved?.id });
|
|
240
|
+
const appUnder = (derivedData) => path.join(derivedData, "Build", "Products", productsDirectory(dev), `${dev.install.productName}.app`);
|
|
241
|
+
// The managed DerivedData of a job's output directory for a call run in `relativeCwd` (snapshot-relative; "" for the snapshot root).
|
|
242
|
+
const appFromOutputDir = (outputDir, { relativeCwd = "" } = {}) => appUnder(path.join(nativeStateDir(outputDir, "xcodebuild", relativeCwd), NATIVE_STATE_LEAVES.derivedData));
|
|
243
|
+
return { build, brokerOwned: true, ...(dev.install ? { appFromOutputDir } : {}), install: dev.install ? { ...dev.install, resolved, app: null } : null };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The built .app of a dev build, from the managed outputs of the xcodebuild call that built it (`result.outputs` of the native runner, role `derived-data`). Refuses when the
|
|
248
|
+
* broker did not report the location: a broker-owned build's output is never guessed.
|
|
249
|
+
*/
|
|
250
|
+
export function iosDevelopmentApp(dev, outputs) {
|
|
251
|
+
const derived = outputs?.ok === true ? outputs.byRole?.["derived-data"] : undefined;
|
|
252
|
+
if (typeof derived !== "string" || !path.isAbsolute(derived)) refuse("DEV_IOS_OUTPUT_UNKNOWN", `the broker did not report the managed derived-data location (${outputs?.ok === false ? outputs.problem : "no verified output directory"}), so there is no built app to install`);
|
|
253
|
+
return path.join(derived, "Build", "Products", productsDirectory(dev), `${dev.install.productName}.app`);
|
|
237
254
|
}
|
|
238
255
|
|
|
239
256
|
// App arguments follow a "--" terminator so an argument such as --device or --json-output is passed to the app and can never be
|
|
@@ -242,10 +259,11 @@ const launchTail = (args) => (args.length ? ["--", ...args] : []);
|
|
|
242
259
|
|
|
243
260
|
// ---- UI test journeys -------------------------------------------------------------------------------------------
|
|
244
261
|
/**
|
|
245
|
-
* Pure: `xcodebuild build-for-testing` then `test-without-building` of the declared journeys on the resolved device or simulator,
|
|
246
|
-
*
|
|
262
|
+
* Pure: `xcodebuild build-for-testing` then `test-without-building` of the declared journeys on the resolved device or simulator, with the same project, configuration and
|
|
263
|
+
* signing. Both calls run in the same directory, so the broker's managed DerivedData is shared between them; the result bundle is the broker's run-unique `result-bundle` role
|
|
264
|
+
* of the test call (no -derivedDataPath or -resultBundlePath is named). The test plan and each selection are admitted shapes (`-testPlan <name>`, `-only-testing:Target[/Class[/test]]`).
|
|
247
265
|
*/
|
|
248
|
-
export function iosUiTestCommands(dev, { root,
|
|
266
|
+
export function iosUiTestCommands(dev, { root, destinationId }) {
|
|
249
267
|
if (!dev.uiTests) throw new Error("development.targets.ios.uiTests is not declared");
|
|
250
268
|
const device = dev.install?.kind === "device";
|
|
251
269
|
const common = [
|
|
@@ -254,23 +272,59 @@ export function iosUiTestCommands(dev, { root, storage, destinationId, resultBun
|
|
|
254
272
|
...(dev.uiTests.testPlan ? ["-testPlan", dev.uiTests.testPlan] : []),
|
|
255
273
|
"-configuration", dev.configuration,
|
|
256
274
|
"-destination", destinationFor(dev.install, destinationId),
|
|
257
|
-
"-derivedDataPath", storage.derivedData,
|
|
258
275
|
...(dev.teamId ? [`DEVELOPMENT_TEAM=${dev.teamId}`] : []),
|
|
259
276
|
...Object.entries(dev.extraBuildSettings).map(([name, value]) => `${name}=${value}`),
|
|
260
277
|
...(device ? ["-allowProvisioningUpdates", "-allowProvisioningDeviceRegistration"] : []),
|
|
261
278
|
];
|
|
262
279
|
return [
|
|
263
280
|
{ cmd: "xcodebuild", name: "xcodebuild build-for-testing", args: [...common, "build-for-testing"] },
|
|
264
|
-
{ cmd: "xcodebuild", name: "xcodebuild test-without-building", args: [...common,
|
|
281
|
+
{ cmd: "xcodebuild", name: "xcodebuild test-without-building", args: [...common, ...dev.uiTests.onlyTesting.map((x) => `-only-testing:${x}`), "test-without-building"] },
|
|
265
282
|
];
|
|
266
283
|
}
|
|
267
284
|
|
|
268
|
-
/** Resolve the device (waking a sleeping tunnel), then the UI test commands; the caller runs them under its lease. */
|
|
269
|
-
export function prepareIosUiTests(dev, { root, env = process.env, run = spawnSync, fs = nodeFs, serial, tmpRoot = os.tmpdir()
|
|
285
|
+
/** Resolve the device (waking a sleeping tunnel), then the UI test commands; the caller runs them with `runIosDevelopmentCommands` under its lease. */
|
|
286
|
+
export function prepareIosUiTests(dev, { root, env = process.env, run = spawnSync, fs = nodeFs, serial, tmpRoot = os.tmpdir() }) {
|
|
270
287
|
const prepared = prepareIosDevelopment(dev, { root, env, run, fs, serial, tmpRoot });
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
288
|
+
// The result bundle is the broker's run-unique `result-bundle` role of the test call, known only from its outputs (`runIosDevelopmentCommands` returns it); there is no path to choose here.
|
|
289
|
+
return { ...prepared, resultBundle: null, tests: iosUiTestCommands(dev, { root, destinationId: prepared.install.resolved?.id }) };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- running the generated commands ---------------------------------------------------------------------------------
|
|
293
|
+
/**
|
|
294
|
+
* Runs generated commands in order through the production native runner (`native`, build-launch.mjs createNativeRunner): xcodegen and xcodebuild are broker adapter jobs and nothing
|
|
295
|
+
* else runs them. EVERY native command is planned first (the door's launch, the broker's grammar and capability), so a refusal stops the whole sequence before the first launch.
|
|
296
|
+
* A command that is not a native build tool (the app's own `prepare` commands) goes to `direct(command)` when the caller supplies one and is otherwise refused; it is never given to a
|
|
297
|
+
* native-tool spawn. Returns { steps: [{ name, result }], outputs } where `outputs` is the LAST xcodebuild call's managed mapping and `resultBundle` the test call's `result-bundle` role.
|
|
298
|
+
*/
|
|
299
|
+
export function runIosDevelopmentCommands(commands, { native, root, env = process.env, direct, log = console.log, timeout } = {}) {
|
|
300
|
+
if (!native || typeof native.run !== "function" || typeof native.plan !== "function") refuse("DEV_IOS_NATIVE_REQUIRED", "the iOS dev and test builds need the production native runner (build-launch.mjs createNativeRunner); xcodegen and xcodebuild run only as broker adapter jobs");
|
|
301
|
+
const cwdOf = (command) => path.resolve(root, command.cwd ?? ".");
|
|
302
|
+
for (const command of commands) {
|
|
303
|
+
if (nativeToolOf(command.cmd) === null) continue;
|
|
304
|
+
try { native.plan(command.cmd, command.args, { cwd: cwdOf(command), env }); } catch (error) {
|
|
305
|
+
if (error?.code === "RK_BUILD_LAUNCH_REFUSED") refuse("DEV_IOS_NATIVE_REFUSED", `${command.name ?? command.cmd} was refused before any launch (${error.reason ?? "refused"}): ${error.message}`);
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const steps = [];
|
|
310
|
+
let outputs = null;
|
|
311
|
+
let resultBundle = null;
|
|
312
|
+
for (const command of commands) {
|
|
313
|
+
const name = command.name ?? command.cmd;
|
|
314
|
+
log(`right-release: ios ${name}`);
|
|
315
|
+
if (nativeToolOf(command.cmd) === null) {
|
|
316
|
+
if (typeof direct !== "function") refuse("DEV_IOS_DIRECT_STEP_UNHANDLED", `${name} is not a native build tool and no direct runner was supplied; it is never run as a native-tool spawn`);
|
|
317
|
+
steps.push({ name, result: direct(command) });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const result = native.run(command.cmd, command.args, { cwd: cwdOf(command), env, ...(timeout ? { timeout } : {}) });
|
|
321
|
+
steps.push({ name, result });
|
|
322
|
+
if (result.refusal) refuse("DEV_IOS_NATIVE_REFUSED", `${name} was refused before any launch (${result.refusal.code}): ${result.refusal.message}`);
|
|
323
|
+
if (!result.ok) refuse("DEV_IOS_BUILD_FAILED", `${name} failed (${result.status ?? "unavailable"})${result.error ? `: ${result.error.message}` : ""}\n${outputTail(result, 20)}`);
|
|
324
|
+
if (command.cmd === "xcodebuild") outputs = result.outputs;
|
|
325
|
+
if (result.outputs?.ok === true && typeof result.outputs.byRole?.["result-bundle"] === "string") resultBundle = result.outputs.byRole["result-bundle"];
|
|
326
|
+
}
|
|
327
|
+
return { steps, outputs, resultBundle };
|
|
274
328
|
}
|
|
275
329
|
|
|
276
330
|
// ---- install ------------------------------------------------------------------------------------------------------
|