@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.
- package/README.md +200 -2
- package/addon-command.mjs +3 -4
- package/android-play-upload.mjs +2 -1
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/build-release.mjs +138 -47
- package/cli/right-release.mjs +40 -5
- package/codesign-environment.mjs +2 -2
- package/command-spec.mjs +80 -0
- package/content-digest.mjs +209 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/development.mjs +17 -6
- package/direct-bootstrap.mjs +5 -6
- package/disk-command.mjs +1 -0
- package/disk-policy.json +6 -2
- package/disk-stable-root.mjs +13 -9
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/github-updater-release.mjs +70 -7
- package/icon-pack.mjs +117 -0
- package/in-place-install.mjs +17 -14
- package/init-command.mjs +116 -0
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +56 -42
- package/ios-asc-credentials.mjs +163 -0
- package/ios-build-context.mjs +129 -0
- package/ios-build-upload.mjs +282 -0
- package/ios-credentials-command.mjs +70 -0
- package/ios-dev.mjs +248 -0
- package/ios-host.mjs +76 -0
- package/ios-lock.mjs +75 -0
- package/ios-package-recipe.mjs +707 -0
- package/ios-testflight.mjs +266 -0
- package/ios-upload.mjs +313 -0
- package/local-publish.mjs +390 -47
- package/macho-approval-hash.mjs +181 -0
- package/managed-cargo.mjs +57 -1
- package/media-sidecar.mjs +20 -4
- package/mirror-root-artifact.mjs +74 -22
- package/native-cargo-layout.mjs +47 -4
- package/npm-license-notices.mjs +159 -0
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +2 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.mjs +66 -16
- package/ownership-repositories.json +1 -1
- package/ownership-scope.mjs +94 -0
- package/ownership.mjs +86 -11
- package/package-recipe-targets.mjs +56 -2
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/preflight.mjs +14 -7
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +83 -5
- package/publish-cargo.mjs +124 -18
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +4 -0
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +62 -24
- package/release.mjs +144 -21
- package/rightkit-ownership.json +46 -2
- package/rightkit-versions.json +380 -189
- package/runtime-budgets.mjs +134 -23
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +634 -69
- package/target-distribution.mjs +5 -0
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/upload-release.mjs +7 -2
- package/web-build.mjs +67 -0
package/release-switch-scan.mjs
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
// "channel": "env", // env | argv | settings | preference | frontend
|
|
37
37
|
// "anchor": "src/test_mode.rs:97", // where it is read (informational)
|
|
38
38
|
// "compiledOut": "feature:test-mode", // required for S1-S4: feature:<name> | cfg:test |
|
|
39
|
-
// // cfg:debug_assertions (read exists only there)
|
|
39
|
+
// // cfg:debug_assertions | swift:DEBUG (read exists only there)
|
|
40
40
|
// "valueType": "bool", // required for S6: bool | int | enum
|
|
41
41
|
// "userGesture": true, // required for S5: chosen by a user gesture
|
|
42
42
|
// "note": "free text" } ],
|
|
@@ -54,7 +54,8 @@ const CHUNK = 4 * 1024 * 1024;
|
|
|
54
54
|
const FORBIDDEN_CLASSES = new Set(["S1", "S2", "S3", "S4"]);
|
|
55
55
|
const CLASSES = new Set(["S1", "S2", "S3", "S4", "S5", "S6"]);
|
|
56
56
|
const CHANNELS = new Set(["env", "argv", "settings", "preference", "frontend"]);
|
|
57
|
-
|
|
57
|
+
// `swift:DEBUG`: the read sits inside `#if DEBUG`, which an Xcode Release configuration compiles out.
|
|
58
|
+
const COMPILED_OUT = /^(feature:[A-Za-z0-9_-]+|cfg:test|cfg:debug_assertions|swift:DEBUG)$/;
|
|
58
59
|
|
|
59
60
|
export const REGISTRY = JSON.parse(readFileSync(new URL("./release-switch-registry.json", import.meta.url), "utf8"));
|
|
60
61
|
|
|
@@ -64,12 +65,13 @@ export const REGISTRY = JSON.parse(readFileSync(new URL("./release-switch-regist
|
|
|
64
65
|
// `cargo tree -e features` lines look like: `├── rightkit-daemon feature "test-mode"` (possibly `(*)` suffixed).
|
|
65
66
|
const FEATURE_LINE = /^[\s│├└─|`+\\-]*([A-Za-z0-9_][A-Za-z0-9_-]*)(?: v[^\s]+)? feature "([^"]+)"/;
|
|
66
67
|
|
|
67
|
-
export function featureClosureFindings(treeText, { forbidden = REGISTRY.forbiddenFeatures } = {}) {
|
|
68
|
+
export function featureClosureFindings(treeText, { forbidden = [...REGISTRY.forbiddenFeatures, ...REGISTRY.conditionalForbiddenFeatures["content-only"]], allowDevtools = false } = {}) {
|
|
68
69
|
const deny = new Set(forbidden);
|
|
69
70
|
const findings = []; const seen = new Set();
|
|
70
71
|
for (const line of String(treeText).split(/\r?\n/)) {
|
|
71
72
|
const m = FEATURE_LINE.exec(line);
|
|
72
|
-
if (!m || !deny.has(m[2])) continue;
|
|
73
|
+
if (!m || (!deny.has(m[2]) && !deny.has(`${m[1]}/${m[2]}`))) continue;
|
|
74
|
+
if (allowDevtools && REGISTRY.conditionalForbiddenFeatures["content-only"].includes(m[2])) continue;
|
|
73
75
|
const id = `forbidden-feature ${m[2]}`;
|
|
74
76
|
const key = `${m[1]}/${m[2]}`;
|
|
75
77
|
if (seen.has(key)) continue;
|
|
@@ -120,7 +122,13 @@ function readPackageScripts(root) {
|
|
|
120
122
|
// skip is `rust === false` (releaseSwitches.rust: false, an explicit "this app has no Rust") and only when no candidate
|
|
121
123
|
// manifest exists. Returns { treeText, manifest } or { skipped: <reason> }. Throws when cargo fails: the caller fails closed.
|
|
122
124
|
// `run` is injectable for tests: (command, args, options) => { status, stdout, stderr, error }.
|
|
123
|
-
export function resolveFeatureClosure(
|
|
125
|
+
export function resolveFeatureClosure(options = {}) {
|
|
126
|
+
return resolveCargoTree(options);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Shared Cargo tree resolver; keeps each build command's feature selection separate. */
|
|
130
|
+
export function resolveCargoTree({ root, manifest, cargoManifest, rust, features = [], scripts, triple, commandTexts = [],
|
|
131
|
+
edges = "features,no-dev", prefix, noDefaultFeatures = false, label = "feature closure", run = spawnSync } = {}) {
|
|
124
132
|
const { rel, declared, candidates } = resolveCargoManifestPath({ root, cargoManifest, manifest });
|
|
125
133
|
if (!rel) {
|
|
126
134
|
if (rust === false) return { skipped: "releaseSwitches.rust is false: the app declares no Rust" };
|
|
@@ -129,7 +137,8 @@ export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, fea
|
|
|
129
137
|
if (declared.length && !declared.some((c) => existsSync(path.join(root, c)))) {
|
|
130
138
|
throw new Error(`release-switch-manifest-missing: declared manifest ${declared.join(", ")} does not exist under ${root}`);
|
|
131
139
|
}
|
|
132
|
-
const baseArgs = ["tree", "
|
|
140
|
+
const baseArgs = ["tree", "--color", "never", "-e", edges, "--locked", "--manifest-path", path.join(root, rel), "--target", triple || "all"];
|
|
141
|
+
if (prefix) baseArgs.push("--prefix", prefix);
|
|
133
142
|
const pkgScripts = scripts ?? readPackageScripts(root);
|
|
134
143
|
// One cargo tree per distinct (features, no-default-features, manifest) combination, one combination per build command
|
|
135
144
|
// (flags gathered over the command and the package.json scripts it runs). A `--no-default-features` in one command never
|
|
@@ -143,30 +152,33 @@ export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, fea
|
|
|
143
152
|
if (script && depth < 3 && Object.hasOwn(pkgScripts, script) && !seen.has(script)) expand(String(pkgScripts[script]), depth + 1, new Set([...seen, script]));
|
|
144
153
|
};
|
|
145
154
|
expand(text, 0, new Set());
|
|
146
|
-
let noDefault =
|
|
155
|
+
let noDefault = noDefaultFeatures;
|
|
156
|
+
let allFeatures = false;
|
|
147
157
|
const enabled = [...features];
|
|
148
158
|
for (const t of expanded) {
|
|
149
159
|
const tokens = commandTokens(t);
|
|
150
160
|
if (tokens.includes("--no-default-features")) noDefault = true;
|
|
161
|
+
if (tokens.includes("--all-features")) allFeatures = true;
|
|
151
162
|
enabled.push(...featuresInTokens(tokens).enabled);
|
|
152
163
|
}
|
|
153
164
|
const list = [...new Set(enabled)].sort();
|
|
154
|
-
combos.set(`${rel}|${noDefault}|${list.join(",")}`, { noDefault, list });
|
|
165
|
+
combos.set(`${rel}|${noDefault}|${allFeatures}|${list.join(",")}`, { noDefault, allFeatures, list });
|
|
155
166
|
};
|
|
156
167
|
const texts = commandTexts.filter(Boolean);
|
|
157
168
|
if (texts.length === 0) comboFor("");
|
|
158
169
|
for (const text of texts) comboFor(text);
|
|
159
170
|
const trees = [];
|
|
160
|
-
for (const { noDefault, list } of combos.values()) {
|
|
171
|
+
for (const { noDefault, allFeatures, list } of combos.values()) {
|
|
161
172
|
const args = [...baseArgs];
|
|
162
173
|
if (noDefault) args.push("--no-default-features");
|
|
174
|
+
if (allFeatures) args.push("--all-features");
|
|
163
175
|
if (list.length) args.push("--features", list.join(","));
|
|
164
176
|
const result = run("cargo", args, { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
165
177
|
if (result.error || result.status !== 0) {
|
|
166
|
-
throw new Error(
|
|
178
|
+
throw new Error(`${label}: \`cargo ${args.join(" ")}\` failed (${result.error?.message ?? `exit ${result.status}`}): ${String(result.stderr ?? "").trim().slice(0, 600)}. The resolved feature graph is the authoritative test-mode check, so a release cannot continue without it.`);
|
|
167
179
|
}
|
|
168
180
|
const out = String(result.stdout ?? "");
|
|
169
|
-
if (!out.trim()) throw new Error(
|
|
181
|
+
if (!out.trim()) throw new Error(`${label}: \`cargo tree\` printed nothing; cannot verify`);
|
|
170
182
|
trees.push(out);
|
|
171
183
|
}
|
|
172
184
|
const treeText = trees.join("\n");
|
|
@@ -181,7 +193,7 @@ export function inventoryDenyNames(inventoryText) {
|
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
// Findings for a build command (`--features test-mode`, `-F`, `--all-features`, ...). Uses the existing tokenizer.
|
|
184
|
-
export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeatures } = {}) {
|
|
196
|
+
export function commandSwitchFindings(argv, { forbidden = [...REGISTRY.forbiddenFeatures, ...REGISTRY.conditionalForbiddenFeatures["content-only"]], allowDevtools = false } = {}) {
|
|
185
197
|
const deny = new Set(forbidden);
|
|
186
198
|
const tokens = argv;
|
|
187
199
|
const enabled = []; let all = false;
|
|
@@ -196,7 +208,8 @@ export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeat
|
|
|
196
208
|
const findings = [];
|
|
197
209
|
for (const item of enabled) {
|
|
198
210
|
const name = item.includes("/") ? item.slice(item.lastIndexOf("/") + 1) : item;
|
|
199
|
-
if (
|
|
211
|
+
if (allowDevtools && REGISTRY.conditionalForbiddenFeatures["content-only"].includes(name)) continue;
|
|
212
|
+
if (deny.has(name) || deny.has(item.replace("?/", "/"))) findings.push({ layer: "feature-closure", rule: `forbidden-feature ${name}`, file: "<command>", reason: `forbidden-feature ${name}: build command enables \`${item}\`` });
|
|
200
213
|
}
|
|
201
214
|
if (all) findings.push({ layer: "feature-closure", rule: "forbidden-feature all-features", file: "<command>", reason: "forbidden-feature all-features: build command enables every feature, including test-mode" });
|
|
202
215
|
return findings;
|
|
@@ -208,7 +221,7 @@ export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeat
|
|
|
208
221
|
export function parseInventory(text, label = "switches.json") {
|
|
209
222
|
const issues = [];
|
|
210
223
|
let doc;
|
|
211
|
-
try { doc = JSON.parse(text); } catch (error) { return { doc: null, issues: [{ rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${label} is not valid JSON (${error.message})` }] }; }
|
|
224
|
+
try { doc = JSON.parse(text); } catch (error) { return { doc: null, issues: [{ layer: "inventory", rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${label} is not valid JSON (${error.message})` }] }; }
|
|
212
225
|
const bad = (reason) => issues.push({ layer: "inventory", rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${reason}` });
|
|
213
226
|
if (doc?.schema !== 1) bad("`schema` must be 1");
|
|
214
227
|
if (typeof doc?.app !== "string" || !doc.app) bad("`app` must be a non-empty string");
|
|
@@ -223,7 +236,7 @@ export function parseInventory(text, label = "switches.json") {
|
|
|
223
236
|
if (!CHANNELS.has(entry.channel)) bad(`${where}: \`channel\` must be one of ${[...CHANNELS].join(", ")}`);
|
|
224
237
|
if (FORBIDDEN_CLASSES.has(entry.class)) {
|
|
225
238
|
if (typeof entry.compiledOut !== "string" || !COMPILED_OUT.test(entry.compiledOut)) {
|
|
226
|
-
issues.push({ layer: "inventory", rule: "not-compiled-out", file: label, reason: `not-compiled-out: ${where} is class ${entry.class} and must declare \`compiledOut\` as feature:<name>, cfg:test
|
|
239
|
+
issues.push({ layer: "inventory", rule: "not-compiled-out", file: label, reason: `not-compiled-out: ${where} is class ${entry.class} and must declare \`compiledOut\` as feature:<name>, cfg:test, cfg:debug_assertions or swift:DEBUG (release builds may not contain it)` });
|
|
227
240
|
}
|
|
228
241
|
} else if (entry.class === "S6") {
|
|
229
242
|
if (!["bool", "int", "enum"].includes(entry.valueType)) bad(`${where}: class S6 needs a bounded \`valueType\` (bool, int or enum)`);
|
|
@@ -235,13 +248,20 @@ export function parseInventory(text, label = "switches.json") {
|
|
|
235
248
|
}
|
|
236
249
|
|
|
237
250
|
const SOURCE_EXT = new Set([".rs", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".jsx", ".vue", ".svelte", ".swift", ".c", ".cc", ".cpp", ".h", ".m", ".mm"]);
|
|
238
|
-
|
|
251
|
+
// Build output and fetched dependencies, never app source: the release recipe's own .right-release/ (Xcode DerivedData, whose
|
|
252
|
+
// SourcePackages/checkouts holds whole dependency repositories), SwiftPM's .build and .swiftpm, and Xcode DerivedData anywhere.
|
|
253
|
+
const SKIP_DIRS = new Set(["node_modules", "target", "dist", "build", ".git", "tests", "__tests__", "benches", "examples", "fixtures", "test", "coverage", ".vite", ".right-release", "deriveddata", "sourcepackages", ".build", ".swiftpm"]);
|
|
254
|
+
// Swift/Xcode test bundles are capitalised (`Tests`, `UITests`, `<App>Tests`, `<App>UITests`): they are test code too.
|
|
255
|
+
const skipDir = (name) => SKIP_DIRS.has(name.toLowerCase()) || /(?:^|[a-z0-9])(?:UI)?Tests$/.test(name);
|
|
239
256
|
const TEST_FILE = /(\.test\.|\.spec\.|_tests?\.rs$|^tests?\.rs$)/;
|
|
240
257
|
|
|
258
|
+
const CARGO_COMPILE_TIME = /\b(?:option_)?env!\(\s*"([^"]+)"/g;
|
|
259
|
+
// Variables Cargo sets for every build script and crate compilation (the Cargo book, "Environment variables Cargo sets for crates").
|
|
260
|
+
const CARGO_SET = /^(CARGO|CARGO_(PKG_[A-Z_]+|MANIFEST_DIR|MANIFEST_PATH|CRATE_NAME|BIN_NAME|BIN_EXE_[A-Za-z0-9_-]+|PRIMARY_PACKAGE|TARGET_TMPDIR|RUSTC_CURRENT_DIR)|OUT_DIR)$/;
|
|
241
261
|
// Reads of a literal name; group 1 is the name.
|
|
242
262
|
const LITERAL_READS = [
|
|
243
263
|
/\b(?:std::)?env::var(?:_os)?\(\s*"([^"]+)"\s*\)/g,
|
|
244
|
-
|
|
264
|
+
CARGO_COMPILE_TIME,
|
|
245
265
|
/\bgetenv\(\s*"([^"]+)"\s*\)/g,
|
|
246
266
|
/process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
247
267
|
/process\.env\[\s*["']([^"']+)["']\s*\]/g,
|
|
@@ -277,7 +297,7 @@ function* walkSource(root) {
|
|
|
277
297
|
for (const entry of entries) {
|
|
278
298
|
const full = path.join(dir, entry.name);
|
|
279
299
|
if (entry.isSymbolicLink()) continue;
|
|
280
|
-
if (entry.isDirectory()) { if (!
|
|
300
|
+
if (entry.isDirectory()) { if (!skipDir(entry.name)) stack.push(full); continue; }
|
|
281
301
|
if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name)) && !TEST_FILE.test(entry.name)) yield full;
|
|
282
302
|
}
|
|
283
303
|
}
|
|
@@ -286,7 +306,7 @@ function* walkSource(root) {
|
|
|
286
306
|
function lineOf(text, index) { let n = 1; for (let i = 0; i < index; i += 1) if (text.charCodeAt(i) === 10) n += 1; return n; }
|
|
287
307
|
|
|
288
308
|
// inventoryText: contents of switches.json. Returns findings only.
|
|
289
|
-
export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel = "switches.json", extraDeny = [] }) {
|
|
309
|
+
export function sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, inventoryLabel = "switches.json", extraDeny = [] }) {
|
|
290
310
|
const { doc, issues } = parseInventory(inventoryText, inventoryLabel);
|
|
291
311
|
const findings = [...issues];
|
|
292
312
|
if (!doc || !Array.isArray(doc.switches)) return findings;
|
|
@@ -294,7 +314,9 @@ export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel
|
|
|
294
314
|
const dynamicAllowed = new Set((doc.dynamicReads ?? []).map((d) => path.normalize(d.file)));
|
|
295
315
|
const reads = new Set();
|
|
296
316
|
const denyFlags = [...REGISTRY.denyNames, ...extraDeny.map((n) => ({ name: typeof n === "string" ? n : n.name }))].map((d) => d.name).filter((n) => n.startsWith("-"));
|
|
297
|
-
|
|
317
|
+
// `sourceRoots` (absolute directories) narrows the walk to what a target compiles; paths stay relative to `sourceRoot`.
|
|
318
|
+
const walkRoots = Array.isArray(sourceRoots) && sourceRoots.length ? sourceRoots : [sourceRoot];
|
|
319
|
+
for (const file of walkRoots.flatMap((walkRoot) => [...walkSource(walkRoot)])) {
|
|
298
320
|
let text;
|
|
299
321
|
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
300
322
|
const rel = path.relative(sourceRoot, file);
|
|
@@ -304,6 +326,9 @@ export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel
|
|
|
304
326
|
for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
|
|
305
327
|
const name = m[1];
|
|
306
328
|
if (JS_BUILTINS.has(name) && /\.(ts|tsx|js|mjs|cjs|jsx|vue|svelte)$/.test(file)) continue;
|
|
329
|
+
// `env!`/`option_env!` of a name Cargo itself sets at compile time (CARGO_PKG_VERSION, CARGO_MANIFEST_DIR, ...) is
|
|
330
|
+
// not a switch: no caller can choose it. Any other compile-time name is still a read the builder could set.
|
|
331
|
+
if (pattern === CARGO_COMPILE_TIME && CARGO_SET.test(name)) continue;
|
|
307
332
|
reads.add(name);
|
|
308
333
|
if (!registered.has(name)) findings.push({ layer: "inventory", rule: "unregistered-read", file: `${rel}:${lineOf(text, m.index)}`, reason: `unregistered-read: \`${name}\` is read here but is not listed in ${inventoryLabel}` });
|
|
309
334
|
}
|
|
@@ -420,18 +445,18 @@ export function scanSwitchPaths(paths, { denyMap = denyNamesFor() } = {}) {
|
|
|
420
445
|
// Every layer is optional input; a layer given no input is skipped, except that `requireBinary` fails a scan that
|
|
421
446
|
// found no native binary. `bypass: ["feature-closure"]` exists only for the negative fixtures that prove the binary
|
|
422
447
|
// layer independently; release.mjs never passes it.
|
|
423
|
-
export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost } = {}) {
|
|
448
|
+
export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sourceRoots, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost, devtools } = {}) {
|
|
424
449
|
const findings = [];
|
|
425
450
|
const bypassed = new Set(bypass);
|
|
426
451
|
const denyMap = denyNamesFor({ appForbid: [...appForbid, ...(inventoryText != null ? inventoryDenyNames(inventoryText) : [])] });
|
|
427
452
|
if (!bypassed.has("feature-closure")) {
|
|
428
453
|
if (treeText != null) {
|
|
429
|
-
findings.push(...featureClosureFindings(treeText));
|
|
454
|
+
findings.push(...featureClosureFindings(treeText, { allowDevtools: devtools?.mode === "content-only" }));
|
|
430
455
|
findings.push(...remoteServiceFindings(treeText, { remoteHost }));
|
|
431
456
|
}
|
|
432
|
-
for (const argv of commands) findings.push(...commandSwitchFindings(argv));
|
|
457
|
+
for (const argv of commands) findings.push(...commandSwitchFindings(argv, { allowDevtools: devtools?.mode === "content-only" }));
|
|
433
458
|
}
|
|
434
|
-
if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, inventoryText, extraDeny: appForbid }));
|
|
459
|
+
if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, extraDeny: appForbid }));
|
|
435
460
|
let receipt = { files: [], binaries: 0 };
|
|
436
461
|
if (binaryPaths.length) {
|
|
437
462
|
const scanned = scanSwitchPaths(binaryPaths, { denyMap });
|
|
@@ -450,3 +475,16 @@ export function assertNoReleaseSwitches(input, label = "release") {
|
|
|
450
475
|
+ "Build release and update tiers without the `test-mode` feature, and register every environment read in the app's switches.json.",
|
|
451
476
|
);
|
|
452
477
|
}
|
|
478
|
+
|
|
479
|
+
/** Local (path) packages in a `cargo tree` text under `root`: the directories of crates a target actually compiles. */
|
|
480
|
+
export function localCrateDirsFromTree(treeText, root) {
|
|
481
|
+
const dirs = new Set();
|
|
482
|
+
const base = path.resolve(root);
|
|
483
|
+
// `name v1 (/abs/path)` and `name v1 (proc-macro) (/abs/path)`; a bare `(*)` or `(proc-macro)` is a marker, not a path.
|
|
484
|
+
for (const m of String(treeText ?? "").matchAll(/ v\S+(?: \(proc-macro\))? \(([^)]+)\)/g)) {
|
|
485
|
+
if (!path.isAbsolute(m[1])) continue;
|
|
486
|
+
const dir = path.resolve(m[1]);
|
|
487
|
+
if (dir === base || dir.startsWith(base + path.sep)) dirs.add(dir);
|
|
488
|
+
}
|
|
489
|
+
return [...dirs].sort();
|
|
490
|
+
}
|
package/release.mjs
CHANGED
|
@@ -11,8 +11,8 @@ import { validateRightKitCargoContract } from "./cargo-contract.mjs";
|
|
|
11
11
|
import { assertCargoProfileContract } from "./cargo-profile-contract.mjs";
|
|
12
12
|
import { assertQaAdoption, assertQaBackdoorContract } from "./qa-contract.mjs";
|
|
13
13
|
import { assertLegalReleaseContract } from "./legal-contract.mjs";
|
|
14
|
-
import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
|
|
15
|
-
import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
14
|
+
import { assertNativeIosPrepareHasManifest, assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
|
|
15
|
+
import { declaredCargoManifest, hasDefaultTauriManifest, resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
16
16
|
import {
|
|
17
17
|
canonicalPackageIdentity,
|
|
18
18
|
createNativeFinalizationReceipt,
|
|
@@ -45,11 +45,15 @@ import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
|
|
|
45
45
|
import { DEFAULT_STORAGE_THRESHOLD_BYTES, findInstallerCopyScripts, findLargeRepositorySubdirectories } from "./installer-storage-doctor.mjs";
|
|
46
46
|
import { macosCodesignEnvironment } from "./codesign-environment.mjs";
|
|
47
47
|
import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs";
|
|
48
|
+
import { assertReleaseDependencies } from "./release-dependencies.mjs";
|
|
48
49
|
import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
|
|
49
50
|
import { expandRecipeBuildCommands } from "./recipe-build.mjs";
|
|
50
|
-
import { assertNoReleaseSwitches, commandTokens, resolveFeatureClosure } from "./release-switch-scan.mjs";
|
|
51
|
+
import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure } from "./release-switch-scan.mjs";
|
|
52
|
+
import { iosOwnershipScope } from "./ownership-scope.mjs";
|
|
51
53
|
import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
|
|
52
54
|
import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
|
|
55
|
+
import { runIosPackageRecipe } from "./ios-package-recipe.mjs";
|
|
56
|
+
import { releaseSwitchInputs } from "./ios-build-context.mjs";
|
|
53
57
|
|
|
54
58
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
55
59
|
const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
|
|
@@ -179,11 +183,14 @@ const SUPERSEDE_JOB_HEAD = /^\n supersede:\n needs: \[private-repo-guard\]\n
|
|
|
179
183
|
|
|
180
184
|
const root = path.dirname(configPath);
|
|
181
185
|
const workdir = path.resolve(root, config.workdir ?? ".");
|
|
182
|
-
|
|
186
|
+
// A native iOS app (xcodegen project; Rust built by iosPackage.prepare) with no src-tauri and no declared nativeAssembly
|
|
187
|
+
// manifest owns no Cargo layout: null, so the Cargo-derived preflight, sweep and package-contract steps are skipped for it.
|
|
188
|
+
const nativeIos = opts.platform === "ios" && !hasDefaultTauriManifest({ appRoot: root });
|
|
189
|
+
const nativeLayout = nativeIos ? null : resolveNativeCargoLayout({ appRoot: root, config });
|
|
183
190
|
const nativeAssembly = config.nativeAssembly;
|
|
184
191
|
const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
|
|
185
|
-
// A SwiftPM app without a package.json has no Node package contract to validate.
|
|
186
|
-
if (!(resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm" && !existsSync(path.join(root, "package.json")))) {
|
|
192
|
+
// A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate.
|
|
193
|
+
if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
|
|
187
194
|
await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
|
|
188
195
|
}
|
|
189
196
|
validateRightKitCargoContract(root, RIGHTKIT_CARGO_ALLOWED, config.app ?? path.basename(root));
|
|
@@ -256,12 +263,18 @@ let androidTarget; // validated Android AAB target (android only; see android-re
|
|
|
256
263
|
if (opts.platform === "android") {
|
|
257
264
|
try { androidTarget = validateAndroidTarget(target, { root }); } catch (error) { fail(`${config.app ?? "app"} android ${error.message}`); }
|
|
258
265
|
}
|
|
266
|
+
let iosSpec; // validated iOS iosPackage spec (ios only; package-recipe-targets.mjs expands targets.ios, ios-package-recipe.mjs runs it)
|
|
267
|
+
if (opts.platform === "ios") {
|
|
268
|
+
if (target.recipe?.kind !== "iosPackage") fail(`${config.app ?? "app"} ios must declare targets.ios.iosPackage; there is no hand-written iOS package script`);
|
|
269
|
+
iosSpec = target.recipe.spec;
|
|
270
|
+
if (nativeIos) { try { assertNativeIosPrepareHasManifest(iosSpec, config, root); } catch (error) { fail(`${config.app ?? "app"} ios ${error.message}`); } }
|
|
271
|
+
}
|
|
259
272
|
if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
|
|
260
273
|
/**
|
|
261
274
|
* Assemble preflight inputs from the app's own files. Kept here (not in
|
|
262
275
|
* preflight.mjs) so the check module stays free of release.mjs's config shape.
|
|
263
276
|
*/
|
|
264
|
-
function releasePreflight({ config, configPath, root, repoRoot, platform, nativeLayout: suppliedNativeLayout = nativeLayout, projectTarget = config.targets?.[platform] }) {
|
|
277
|
+
function releasePreflight({ config, configPath, root, repoRoot, platform, nativeLayout: suppliedNativeLayout = nativeLayout, projectTarget = config.targets?.[platform], pinnedCargoLocks = [] }) {
|
|
265
278
|
let version;
|
|
266
279
|
try {
|
|
267
280
|
version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version;
|
|
@@ -272,6 +285,7 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
|
|
|
272
285
|
// them means the build needs a real Windows Perl.
|
|
273
286
|
const cargoLockPaths = [
|
|
274
287
|
...(suppliedNativeLayout?.lockCandidates ?? []),
|
|
288
|
+
...pinnedCargoLocks,
|
|
275
289
|
...(config.cargoLocks ?? []).map((entry) => path.resolve(root, entry)),
|
|
276
290
|
].filter((entry, index, entries) => entries.indexOf(entry) === index);
|
|
277
291
|
return collectPreflight({
|
|
@@ -285,12 +299,14 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
|
|
|
285
299
|
nativeLayout: suppliedNativeLayout,
|
|
286
300
|
targetLink: suppliedNativeLayout?.targetLink,
|
|
287
301
|
projectType: resolveProjectType({ appRoot: root, config, target: projectTarget }),
|
|
302
|
+
rust: Boolean(suppliedNativeLayout) || pinnedCargoLocks.length > 0,
|
|
303
|
+
bridge: Boolean(suppliedNativeLayout),
|
|
288
304
|
msix: targetUsesMsix(projectTarget),
|
|
289
305
|
});
|
|
290
306
|
}
|
|
291
307
|
|
|
292
308
|
if (opts.doctor) {
|
|
293
|
-
const { buildInputs, nativeLayout: resolvedNativeLayout } = resolveReleaseBuildInputs({
|
|
309
|
+
const { buildInputs, nativeLayout: resolvedNativeLayout, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({
|
|
294
310
|
repoRoot: doctorInvocation.repoRoot,
|
|
295
311
|
appRoot: root,
|
|
296
312
|
configPath,
|
|
@@ -334,7 +350,7 @@ if (opts.doctor) {
|
|
|
334
350
|
|
|
335
351
|
// Config above, MACHINE below. Printing config never told anyone whether this
|
|
336
352
|
// box could finish a build; these checks do.
|
|
337
|
-
const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout });
|
|
353
|
+
const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout, pinnedCargoLocks });
|
|
338
354
|
console.log("preflight:");
|
|
339
355
|
console.log(formatPreflight(checks));
|
|
340
356
|
const failures = preflightFailures(checks);
|
|
@@ -349,10 +365,15 @@ let prePackScanned = false; // set once a pre-pack payload scan has seen a nativ
|
|
|
349
365
|
// or app Cargo manifest enables devtools, qa-native, qa-control or dev-native (also in --dry-run).
|
|
350
366
|
// DevTools: refused for every app except an allow-listed one that declares `devtools: "content-only"` (devtools-policy.mjs).
|
|
351
367
|
let devtoolsAllowance = { mode: "denied" };
|
|
368
|
+
// The Rust a build compiles is checked from the manifest it compiles: targets.<os>.cargoManifest, else the declared
|
|
369
|
+
// nativeAssembly.cargoManifest. iosPackage.prepare commands run cargo for a native iOS app, so they are scanned like any build command.
|
|
370
|
+
const switchInputs = releaseSwitchInputs({ appRoot: root, config, target, prepare: iosSpec?.prepare ?? [], nativeIos });
|
|
371
|
+
if (switchInputs.problems.length) fail(`${config.app ?? "app"} ios release-switch scan cannot see what is built:\n- ${switchInputs.problems.join("\n- ")}`);
|
|
352
372
|
try {
|
|
353
373
|
devtoolsAllowance = resolveDevtoolsAllowance({ config, root });
|
|
354
374
|
if (devtoolsAllowance.mode === "content-only") console.log(`right-release: devtools allowance recorded: ${JSON.stringify(devtoolsEvidence(devtoolsAllowance))}`);
|
|
355
|
-
assertNoForbiddenBuildFeatures({ root, tier: "release/update", devtools: devtoolsAllowance, cargoManifest:
|
|
375
|
+
assertNoForbiddenBuildFeatures({ root, tier: "release/update", devtools: devtoolsAllowance, cargoManifest: switchInputs.cargoManifest, manifest: config.releaseSwitches?.manifest, commands: [
|
|
376
|
+
...switchInputs.commands,
|
|
356
377
|
{ label: "target.prePackage", command: target.prePackage },
|
|
357
378
|
{ label: "target.package", command: target.package },
|
|
358
379
|
{ label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
|
|
@@ -373,16 +394,18 @@ const releaseSwitchInventoryText = existsSync(releaseSwitchInventoryPath) ? read
|
|
|
373
394
|
try {
|
|
374
395
|
// A `node recipe-build.mjs --plan <JSON>` prePackage hides each step's `--features` in one argument: the closure and the
|
|
375
396
|
// command token scan see every prepare / build / postBuild step (a plan that cannot be read or validated fails closed).
|
|
376
|
-
const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
|
|
397
|
+
const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle, ...switchInputs.cargoCommands].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
|
|
398
|
+
assertReleaseDependencies({ root, config, target, commands: closureCommands });
|
|
377
399
|
let treeText;
|
|
378
400
|
try {
|
|
379
401
|
const closure = resolveFeatureClosure({
|
|
380
402
|
root,
|
|
381
403
|
manifest: config.releaseSwitches?.manifest,
|
|
382
|
-
cargoManifest:
|
|
404
|
+
cargoManifest: switchInputs.cargoManifest,
|
|
383
405
|
rust: config.releaseSwitches?.rust,
|
|
384
406
|
features: config.releaseSwitches?.features ?? [],
|
|
385
|
-
triple
|
|
407
|
+
// iOS ships one device triple; "all" is refused by the build broker and would pull in desktop-only targets.
|
|
408
|
+
triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? (opts.platform === "ios" ? "aarch64-apple-ios" : undefined),
|
|
386
409
|
commandTexts: closureCommands,
|
|
387
410
|
});
|
|
388
411
|
if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
|
|
@@ -392,9 +415,26 @@ try {
|
|
|
392
415
|
console.log(`right-release: dry run: ${error.message}`);
|
|
393
416
|
}
|
|
394
417
|
// remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
|
|
395
|
-
const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: closureCommands.map((c) => commandTokens(c)) };
|
|
418
|
+
const closureInput = { treeText, devtools: devtoolsAllowance, remoteHost: config.releaseSwitches?.remoteHost, commands: closureCommands.map((c) => commandTokens(c)) };
|
|
396
419
|
if (releaseSwitchInventoryText != null) {
|
|
397
|
-
|
|
420
|
+
// iOS: only what the iPhone app compiles — the Xcode project and its local packages, plus the local crates in the
|
|
421
|
+
// resolved aarch64-apple-ios cargo graph (feature-aware, unlike the ownership closure). Other targets walk the app root.
|
|
422
|
+
let sourceRoots;
|
|
423
|
+
if (opts.platform === "ios") {
|
|
424
|
+
const repoTop = git(root, ["rev-parse", "--show-toplevel"]);
|
|
425
|
+
// Swift side only: the Rust side is the resolved cargo graph below. The ownership scope also folds in the whole
|
|
426
|
+
// nativeAssembly workspace, which would scan crates (desktop tools, the TUI) the iPhone never links.
|
|
427
|
+
const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: root, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
|
|
428
|
+
sourceRoots = [...new Set([...swiftSide, ...localCrateDirsFromTree(treeText, repoTop)])].sort();
|
|
429
|
+
// A prepare step can stage sources from anywhere (a web bundle, a generated resource) into the app; its working directory must be
|
|
430
|
+
// inside the scanned roots, or the release fails instead of shipping what the scan never saw. Add the directory to ownershipScope.
|
|
431
|
+
for (const step of config.targets?.ios?.iosPackage?.prepare ?? []) {
|
|
432
|
+
const cwd = path.resolve(root, step?.cwd ?? ".");
|
|
433
|
+
if (!sourceRoots.some((dir) => cwd === dir || cwd.startsWith(dir + path.sep))) throw new Error(`source switch inventory: targets.ios.iosPackage.prepare runs in ${path.relative(repoTop, cwd) || "."}, outside the iOS scan roots (${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}); list that directory in targets.ios.ownershipScope`);
|
|
434
|
+
}
|
|
435
|
+
console.log(`right-release: source switch scan (ios): ${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}`);
|
|
436
|
+
}
|
|
437
|
+
assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, sourceRoots, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
|
|
398
438
|
} else if (treeText != null || closureInput.commands.length) {
|
|
399
439
|
assertNoReleaseSwitches({ ...closureInput, appForbid: config.releaseSwitches?.forbid ?? [] }, "feature closure");
|
|
400
440
|
}
|
|
@@ -406,7 +446,7 @@ try {
|
|
|
406
446
|
const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
|
|
407
447
|
const mode = "package";
|
|
408
448
|
const command = target.package;
|
|
409
|
-
if (!command && opts.platform !== "android" && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }))) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
|
|
449
|
+
if (!command && opts.platform !== "android" && opts.platform !== "ios" && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }))) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
|
|
410
450
|
if (opts.upload && !target.publish && !target.upload) {
|
|
411
451
|
fail(`${config.app ?? "app"} has no ${opts.platform} upload config`);
|
|
412
452
|
}
|
|
@@ -522,6 +562,16 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
|
|
|
522
562
|
receiptDir: path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts")),
|
|
523
563
|
allowContentOnlyDevtools: devtoolsAllowance.mode === "content-only",
|
|
524
564
|
});
|
|
565
|
+
} else if (opts.platform === "ios") {
|
|
566
|
+
// Archive and export with Xcode automatic signing in the GUI session, then the IPA verification and the receipt (macOS only;
|
|
567
|
+
// --dry-run prints the plan anywhere). Upload is a separate command: `right-release upload --platform ios`.
|
|
568
|
+
await runIosPackageRecipe({
|
|
569
|
+
root, app: config.app ?? path.basename(root), spec: iosSpec, dryRun: opts.dryRun,
|
|
570
|
+
receiptDir: path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts")),
|
|
571
|
+
scanFiles: (label, files, scanOptions) => assertNoQaControlBinaries(label, files, scanOptions),
|
|
572
|
+
// The build number counter outlives every release id: it lives in the vault state root that right-release build exports.
|
|
573
|
+
...(process.env.RIGHT_RELEASE_VAULT_STATE_ROOT ? { counterDir: path.join(path.resolve(process.env.RIGHT_RELEASE_VAULT_STATE_ROOT), "ios-build-numbers") } : {}),
|
|
574
|
+
});
|
|
525
575
|
} else if (nativeAssembly) {
|
|
526
576
|
if (nativeAssembly.packageHook) await runCommand(nativeAssembly.packageHook, root);
|
|
527
577
|
if (nativeFinalizer) {
|
|
@@ -645,7 +695,7 @@ function assertNoQaControlBinaries(label, files, { requireBinary = false, prePac
|
|
|
645
695
|
try {
|
|
646
696
|
const stats = assertNoQaControl(files, label, { requireBinary, allowContentOnlyDevtools: devtoolsAllowance.mode === "content-only" });
|
|
647
697
|
// D4 binary layer, same inputs: no test-mode marker and no S1-S4 selector name in any native binary.
|
|
648
|
-
assertNoReleaseSwitches({ binaryPaths: files, inventoryText: releaseSwitchInventoryText ?? undefined, appForbid: config.releaseSwitches?.forbid ?? [], requireBinary: false }, `${label} (release switches)`);
|
|
698
|
+
assertNoReleaseSwitches({ binaryPaths: files, devtools: devtoolsAllowance, inventoryText: releaseSwitchInventoryText ?? undefined, appForbid: config.releaseSwitches?.forbid ?? [], requireBinary: false }, `${label} (release switches)`);
|
|
649
699
|
if (prePack && stats.binaries > 0) prePackScanned = true;
|
|
650
700
|
for (const opaque of stats.opaque) {
|
|
651
701
|
console.log(`right-release: ${label}: ${opaque.kind.toUpperCase()} payload is compressed and cannot be scanned (${opaque.file}); the raw payload was scanned before packing`);
|
|
@@ -906,6 +956,8 @@ function hasNativeTargetSpecificPrePackage(target, packageHook) {
|
|
|
906
956
|
// publish so it cannot race an artifact, and is best-effort: a missing
|
|
907
957
|
// cargo-sweep or a sweep failure must never fail a release.
|
|
908
958
|
function sweepStaleRustArtifacts() {
|
|
959
|
+
// No Cargo host, nothing of ours to sweep.
|
|
960
|
+
if (!nativeLayout) return;
|
|
909
961
|
const candidates = [...new Set([root, workdir, nativeLayout.manifestDir, nativeLayout.workspaceRoot])];
|
|
910
962
|
const projects = candidates.filter((dir) => existsSync(path.join(dir, "target")));
|
|
911
963
|
for (const dir of projects) {
|
|
@@ -1132,9 +1184,13 @@ function signExistingRefusal(source) {
|
|
|
1132
1184
|
// The renderer comes from this monorepo's own packages/ci when present, else from the repository's installed @rightkit/ci; with
|
|
1133
1185
|
// neither the file is refused, because an unverifiable sign-existing.yml must not be accepted.
|
|
1134
1186
|
async function loadRightKitCiRenderer(root) {
|
|
1135
|
-
|
|
1187
|
+
return (await loadRightKitCiModule(root, "sync.mjs", "renderLanes"))?.renderLanes ?? null;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function loadRightKitCiModule(root, file, exportName) {
|
|
1191
|
+
const candidates = [pathToFileURL(fileURLToPath(new URL(`../ci/${file}`, import.meta.url))).href];
|
|
1136
1192
|
try {
|
|
1137
|
-
candidates.push(pathToFileURL(createRequire(path.join(root, "package.json")).resolve(
|
|
1193
|
+
candidates.push(pathToFileURL(createRequire(path.join(root, "package.json")).resolve(`@rightkit/ci/${file}`)).href);
|
|
1138
1194
|
} catch {
|
|
1139
1195
|
// not installed in the repository; the sibling candidate may still apply
|
|
1140
1196
|
}
|
|
@@ -1142,7 +1198,7 @@ async function loadRightKitCiRenderer(root) {
|
|
|
1142
1198
|
try {
|
|
1143
1199
|
if (href.startsWith("file:") && !existsSync(fileURLToPath(href))) continue;
|
|
1144
1200
|
const loaded = await import(href);
|
|
1145
|
-
if (typeof loaded
|
|
1201
|
+
if (typeof loaded[exportName] === "function") return loaded;
|
|
1146
1202
|
} catch {
|
|
1147
1203
|
// try the next candidate
|
|
1148
1204
|
}
|
|
@@ -1165,6 +1221,57 @@ async function signExistingRenderRefusal(root, manifest, source) {
|
|
|
1165
1221
|
}
|
|
1166
1222
|
|
|
1167
1223
|
|
|
1224
|
+
// `workflows` block entries render from code, so they are admitted only when the block passes @rightkit/ci's own
|
|
1225
|
+
// validateWorkflows and each file is byte-for-byte the @rightkit/ci render. With no resolvable @rightkit/ci they are refused.
|
|
1226
|
+
async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles) {
|
|
1227
|
+
const validator = (await loadRightKitCiModule(root, "workflows.mjs", "validateWorkflows"))?.validateWorkflows;
|
|
1228
|
+
const renderLanes = await loadRightKitCiRenderer(root);
|
|
1229
|
+
if (!validator || !renderLanes) fail(`${appName} right-git-ci-only policy cannot verify .rightgit.json workflows: @rightkit/ci is not available (install @rightkit/ci as a devDependency)`);
|
|
1230
|
+
const errors = [];
|
|
1231
|
+
validator(manifest, errors);
|
|
1232
|
+
if (errors.length > 0) fail(`${appName} right-git-ci-only policy refuses the .rightgit.json workflows block: ${errors.join("; ")}`);
|
|
1233
|
+
let rendered;
|
|
1234
|
+
try {
|
|
1235
|
+
rendered = renderLanes(manifest, root);
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
fail(`${appName} right-git-ci-only policy cannot render the .rightgit.json workflows block: ${error.message}`);
|
|
1238
|
+
}
|
|
1239
|
+
for (const file of declaredFiles) {
|
|
1240
|
+
const expected = rendered.find((entry) => entry.filename === file)?.content;
|
|
1241
|
+
const actual = await readFile(path.join(root, ".github", "workflows", file), "utf8");
|
|
1242
|
+
if (typeof expected !== "string" || actual.replace(/\r\n?/g, "\n") !== expected.replace(/\r\n?/g, "\n")) {
|
|
1243
|
+
fail(`${appName} ${file} is not byte-for-byte the @rightkit/ci render of its .rightgit.json workflows entry (run right-git sync)`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Structural backstop for declared workflows (after render equality): the one top-level permissions block is exactly
|
|
1249
|
+
// `contents: read`, no other permissions key exists, no secrets reference, every `uses:` is commit-pinned, and every
|
|
1250
|
+
// checkout step sets persist-credentials: false. Steps are split on step-list indentation, not matched as free text.
|
|
1251
|
+
function declaredWorkflowPolicyRefusal(appName, workflow, source) {
|
|
1252
|
+
const refuse = (why) => fail(`${appName} right-git managed ${workflow} violates the declared-workflow policy: ${why}`);
|
|
1253
|
+
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
1254
|
+
const permissionsAt = lines.flatMap((line, i) => (/^\s*permissions\s*:/.test(line) ? [i] : []));
|
|
1255
|
+
if (permissionsAt.length !== 1 || lines[permissionsAt[0]] !== "permissions:" || lines[permissionsAt[0] + 1] !== " contents: read" || /^ {2,}\S/.test(lines[permissionsAt[0] + 2] ?? "")) refuse("permissions must be exactly one top-level `contents: read`");
|
|
1256
|
+
if (/\bsecrets\b/i.test(source)) refuse("it references the secrets context");
|
|
1257
|
+
let step = null;
|
|
1258
|
+
const steps = [];
|
|
1259
|
+
for (const line of lines) {
|
|
1260
|
+
if (/^ {6}- /.test(line)) { step = [line]; steps.push(step); }
|
|
1261
|
+
else if (step && (/^ {7,}\S/.test(line) || line === "")) step.push(line);
|
|
1262
|
+
else step = null;
|
|
1263
|
+
}
|
|
1264
|
+
for (const body of steps) {
|
|
1265
|
+
const uses = /^ {6}- uses: (\S+)|^ {8}uses: (\S+)/m.exec(body.join("\n"));
|
|
1266
|
+
if (!uses) continue;
|
|
1267
|
+
const ref = uses[1] ?? uses[2];
|
|
1268
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+@[0-9a-f]{40}$/.test(ref)) refuse(`action ${ref} is not commit-pinned`);
|
|
1269
|
+
if (/^actions\/checkout@/.test(ref) && !body.some((line) => /^ {10}persist-credentials: false\s*$/.test(line))) refuse("a checkout step does not set persist-credentials: false");
|
|
1270
|
+
if (body.some((line) => /persist-credentials:\s*true/.test(line))) refuse("persist-credentials: true");
|
|
1271
|
+
}
|
|
1272
|
+
if (/^\s*(?:- )?uses:/m.test(lines.filter((l) => !steps.some((b) => b.includes(l))).join("\n"))) refuse("a uses: sits outside a step list");
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1168
1275
|
async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
1169
1276
|
if (policy !== "right-git-ci-only") {
|
|
1170
1277
|
fail(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")}`);
|
|
@@ -1188,15 +1295,29 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
|
1188
1295
|
|| lanes.some((lane) => !safeLanes.has(lane))) {
|
|
1189
1296
|
fail(`${appName} right-git-ci-only policy requires compile=github-actions-only plus unique .rightgit.json lanes drawn from [${[...safeLanes].map((lane) => `"${lane}"`).join(", ")}] and including "ci" or "swift-check"`);
|
|
1190
1297
|
}
|
|
1191
|
-
|
|
1298
|
+
// Workflows declared under the manifest's `workflows` block (@rightkit/ci workflows.mjs: explicit `file`,
|
|
1299
|
+
// else `<name>.yml`) are managed files too, in addition to the lane files.
|
|
1300
|
+
const declaredWorkflows = manifest?.workflows;
|
|
1301
|
+
if (declaredWorkflows !== undefined && (declaredWorkflows === null || typeof declaredWorkflows !== "object" || Array.isArray(declaredWorkflows))) {
|
|
1302
|
+
fail(`${appName} right-git-ci-only policy requires .rightgit.json workflows to be an object`);
|
|
1303
|
+
}
|
|
1304
|
+
const declaredFiles = Object.entries(declaredWorkflows ?? {}).map(([name, config]) => {
|
|
1305
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) fail(`${appName} right-git-ci-only policy requires .rightgit.json workflows.${name} to be an object`);
|
|
1306
|
+
const file = typeof config?.file === "string" ? config.file : `${name}.yml`;
|
|
1307
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,60}\.yml$/.test(file)) fail(`${appName} right-git-ci-only policy refuses workflow file name ${JSON.stringify(file)} declared by .rightgit.json`);
|
|
1308
|
+
return file;
|
|
1309
|
+
});
|
|
1310
|
+
const expectedWorkflows = [...new Set([...lanes.map((lane) => `${lane}.yml`), ...declaredFiles])].sort();
|
|
1192
1311
|
if (JSON.stringify(workflows) !== JSON.stringify(expectedWorkflows)) {
|
|
1193
1312
|
fail(`${appName} right-git-ci-only policy requires workflows declared by .rightgit.json; expected ${expectedWorkflows.join(", ")}, got: ${workflows.join(", ")}`);
|
|
1194
1313
|
}
|
|
1314
|
+
if (declaredFiles.length > 0) await validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles);
|
|
1195
1315
|
for (const workflow of expectedWorkflows) {
|
|
1196
1316
|
const source = await readFile(path.join(root, ".github", "workflows", workflow), "utf8");
|
|
1197
1317
|
if (!source.startsWith("# Managed by right-git")) {
|
|
1198
1318
|
fail(`${appName} ${workflow} is not marked as right-git managed`);
|
|
1199
1319
|
}
|
|
1320
|
+
if (declaredFiles.includes(workflow)) declaredWorkflowPolicyRefusal(appName, workflow, source);
|
|
1200
1321
|
const jobSpan = (jobName) => {
|
|
1201
1322
|
const marker = `\n ${jobName}:\n`;
|
|
1202
1323
|
const start = source.indexOf(marker);
|
|
@@ -1361,6 +1482,8 @@ function expandEnv(value) {
|
|
|
1361
1482
|
// notices and the source offer, optionally run the app's stage command, and (before signing) check the staged files.
|
|
1362
1483
|
async function prepareMediaSidecar(root, { stage = true } = {}) {
|
|
1363
1484
|
if (!mediaSidecarConfig) return null;
|
|
1485
|
+
// Unreachable for iOS (mediaSidecar is mac and win only, refused above); a Cargo-less layout must still never be dereferenced.
|
|
1486
|
+
if (!nativeLayout) fail(`${config.app ?? "app"} ${opts.platform} mediaSidecar needs a native Cargo manifest to read the sidecar catalog, and this app has none`);
|
|
1364
1487
|
let plan;
|
|
1365
1488
|
try {
|
|
1366
1489
|
const loaded = loadSidecarCatalog({ config: mediaSidecarConfig, root, manifestPath: nativeLayout.manifestPath });
|