@rightkit/release 0.3.0 → 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 +25 -4
- 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 +11 -2
- package/codesign-environment.mjs +2 -2
- package/content-digest.mjs +209 -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/github-updater-release.mjs +70 -7
- package/in-place-install.mjs +17 -14
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +21 -40
- 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 +92 -37
- package/managed-cargo.mjs +8 -0
- package/mirror-root-artifact.mjs +5 -1
- package/native-cargo-layout.mjs +47 -4
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +1 -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 +49 -7
- package/package-recipe-targets.mjs +41 -1
- package/package.json +1 -1
- 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 +15 -4
- package/publish-cargo.mjs +109 -22
- 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-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +45 -17
- package/release.mjs +69 -17
- package/rightkit-ownership.json +1 -1
- package/rightkit-versions.json +379 -189
- package/runtime-budgets.mjs +120 -16
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +160 -32
- package/target-distribution.mjs +5 -0
- package/upload-release.mjs +7 -2
package/release-invocation.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { spawnSync } from "./background-process.mjs";
|
|
|
2
2
|
import { resolveProjectType } from "./project-type.mjs";
|
|
3
3
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
5
|
+
import { declaredCargoManifest, hasDefaultTauriManifest, resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
6
6
|
|
|
7
7
|
function git(cwd, args) {
|
|
8
8
|
const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
|
|
@@ -129,20 +129,128 @@ export function resolveConfiguredBuildInputs(config, target, label = "release co
|
|
|
129
129
|
return configured;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/** The validated or raw iOS package spec of a target (`targets.ios.iosPackage`, or the recipe the build expands it to). */
|
|
133
|
+
export function iosPackageSpecOf(target) {
|
|
134
|
+
if (target?.recipe?.kind === "iosPackage") return target.recipe.spec ?? {};
|
|
135
|
+
if (target?.iosPackage && typeof target.iosPackage === "object") return target.iosPackage;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Native iOS mode: an iOS target with iosPackage and no src-tauri/Cargo.toml (a declared nativeAssembly.cargoManifest does not
|
|
141
|
+
* leave it). The app is an xcodegen project built by xcodebuild, not a Tauri host: no broker target, no target bridge, no
|
|
142
|
+
* Tauri layout. A declared manifest only pins its Cargo.toml and Cargo.lock as inputs; iosPackage.prepare runs the cargo build.
|
|
143
|
+
* An iOS app that has src-tauri/Cargo.toml (Pulse) keeps the Cargo-backed behaviour exactly.
|
|
144
|
+
*/
|
|
145
|
+
export function isNativeIosTarget({ appRoot, target, exists } = {}) {
|
|
146
|
+
return iosPackageSpecOf(target) !== null && !hasDefaultTauriManifest({ appRoot, exists });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* prepare commands exist to build the app's Rust static library. Without a declared nativeAssembly.cargoManifest the release
|
|
151
|
+
* would hash and seal none of that Rust source graph, so a native iOS target may not carry them.
|
|
152
|
+
*/
|
|
153
|
+
export function assertNativeIosPrepareHasManifest(iosSpec, config, appRoot) {
|
|
154
|
+
if (declaredCargoManifest(config)) {
|
|
155
|
+
const problems = nativeIosPrepareProblems({ appRoot, config, prepare: iosSpec?.prepare ?? [] });
|
|
156
|
+
if (problems.length) throw new Error(`iosPackage.prepare must be only bare cargo commands when a Rust manifest is declared; express the Rust build as cargo commands in prepare (no node, scripts, pnpm, python, rustup run, +toolchain or other manifest-path), so the features it enables can be checked and the release pins exactly the manifest it builds:\n- ${problems.join("\n- ")}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (Array.isArray(iosSpec?.prepare) && iosSpec.prepare.length > 0) {
|
|
160
|
+
throw new Error("targets.ios.iosPackage.prepare builds a Rust library but no Cargo manifest is declared; declare nativeAssembly.cargoManifest (and keep its Cargo.lock beside it, or name it with nativeAssembly.cargoLockPath) so the Rust manifest and lock are pinned as required release inputs");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const FEATURE_FLAGS = new Set(["--features", "-F", "--feature"]);
|
|
165
|
+
// The only cargo subcommands a native iOS prepare may run: each takes --features and builds exactly one manifest. xtask, aliases
|
|
166
|
+
// (which .cargo/config.toml can point anywhere) and cargo-<custom> subcommands are opaque to the feature scan.
|
|
167
|
+
const PREPARE_SUBCOMMANDS = new Set(["build", "rustc", "check"]);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* With a declared Rust manifest, every prepare command is a bare `cargo`: nothing else can be scanned for the features it enables
|
|
171
|
+
* or be trusted to build only the manifest the release pins. Returns the reasons a command is refused (empty when all are fine).
|
|
172
|
+
*/
|
|
173
|
+
export function nativeIosPrepareProblems({ appRoot, config, prepare = [] }) {
|
|
174
|
+
const declared = declaredCargoManifest(config);
|
|
175
|
+
if (!declared) return [];
|
|
176
|
+
const manifest = path.resolve(appRoot, declared);
|
|
177
|
+
const problems = [];
|
|
178
|
+
prepare.forEach((command, index) => {
|
|
179
|
+
const where = `targets.ios.iosPackage.prepare[${index}] (${command?.cmd})`;
|
|
180
|
+
if (command?.cmd !== "cargo") { problems.push(`${where} is not a bare cargo command`); return; }
|
|
181
|
+
const args = (command.args ?? []).map(String);
|
|
182
|
+
if (args[0]?.startsWith("+")) problems.push(`${where} selects a toolchain with ${args[0]}`);
|
|
183
|
+
else if (!PREPARE_SUBCOMMANDS.has(args[0])) problems.push(`${where} runs cargo ${args[0] ?? "(no subcommand)"}; only cargo build, rustc and check are allowed (no xtask, alias or cargo-<custom> subcommand)`);
|
|
184
|
+
const cwd = path.resolve(appRoot, command.cwd ?? ".");
|
|
185
|
+
let manifestPathSeen = false;
|
|
186
|
+
args.forEach((arg, position) => {
|
|
187
|
+
if (arg === "--config" || arg.startsWith("--config=")) problems.push(`${where} passes ${arg.split("=")[0]}, which can change what cargo builds`);
|
|
188
|
+
if (arg === "-Z" || /^-Z.+/.test(arg)) problems.push(`${where} passes the unstable flag ${arg.split("=")[0]}`);
|
|
189
|
+
if (arg === "--target-dir" || arg.startsWith("--target-dir=")) problems.push(`${where} passes --target-dir; build output goes where the release expects it`);
|
|
190
|
+
const pathValue = arg === "--manifest-path" ? args[position + 1] : arg.startsWith("--manifest-path=") ? arg.slice("--manifest-path=".length) : undefined;
|
|
191
|
+
if (pathValue !== undefined) manifestPathSeen = true;
|
|
192
|
+
if (pathValue !== undefined && path.relative(path.resolve(cwd, pathValue), manifest) !== "") problems.push(`${where} builds ${pathValue}, not the declared manifest ${declared}`);
|
|
193
|
+
const featureValue = FEATURE_FLAGS.has(arg) ? args[position + 1] : arg.startsWith("--features=") ? arg.slice("--features=".length) : /^-F.+/.test(arg) ? arg.slice(2) : undefined;
|
|
194
|
+
if (featureValue !== undefined && /[$%`]/.test(featureValue)) problems.push(`${where} selects features from a variable (${arg}); the Cargo features it enables cannot be determined`);
|
|
195
|
+
});
|
|
196
|
+
// No cwd fallback: cargo would otherwise build whatever Cargo.toml the working directory holds.
|
|
197
|
+
if (!manifestPathSeen) problems.push(`${where} has no --manifest-path; pass --manifest-path ${declared} so it builds exactly the declared manifest`);
|
|
198
|
+
// The lock is hashed before prepare runs; without --locked cargo may rewrite it and build different dependencies.
|
|
199
|
+
if (!args.some((arg) => arg === "--locked" || arg === "--frozen")) problems.push(`${where} has no --locked; pass --locked so cargo builds exactly the pinned Cargo.lock`);
|
|
200
|
+
});
|
|
201
|
+
return problems;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The repo-relative-to-app Package.resolved an iosPackage with trusted Swift macros/plugins pins, or null when it trusts none. */
|
|
205
|
+
export function iosTrustPackageResolved(iosSpec) {
|
|
206
|
+
const listed = (value) => Array.isArray(value) && value.length > 0;
|
|
207
|
+
if (!listed(iosSpec?.trustedSwiftMacros) && !listed(iosSpec?.trustedSwiftPlugins)) return null;
|
|
208
|
+
if (typeof iosSpec.packageResolved === "string" && iosSpec.packageResolved) return iosSpec.packageResolved;
|
|
209
|
+
if (typeof iosSpec.project !== "string" || !iosSpec.project) return null;
|
|
210
|
+
return path.posix.join(iosSpec.project.replaceAll("\\", "/"), "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const outside = (root, file) => { const relative = path.relative(root, file); return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); };
|
|
214
|
+
|
|
132
215
|
export function resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target, env = process.env }) {
|
|
133
216
|
const swift = resolveProjectType({ appRoot, config, target }) === "swiftpm";
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
217
|
+
const iosSpec = iosPackageSpecOf(target);
|
|
218
|
+
const nativeIos = !swift && isNativeIosTarget({ appRoot, target });
|
|
219
|
+
if (nativeIos) assertNativeIosPrepareHasManifest(iosSpec, config, appRoot);
|
|
220
|
+
// Native iOS: a declared manifest is resolved only to pin its Cargo.toml and lock (refused when there is no lock, or when
|
|
221
|
+
// either lies outside the repository). The returned layout is null, so no consumer links a broker target or bridges a cargo target.
|
|
222
|
+
const declaredManifest = nativeIos ? declaredCargoManifest(config) : undefined;
|
|
223
|
+
if (declaredManifest && outside(repoRoot, path.resolve(appRoot, declaredManifest))) throw new Error(`declared Cargo manifest ${declaredManifest} is outside the repository ${repoRoot}; a release pins only sources it can seal`);
|
|
224
|
+
const pinnedLayout = nativeIos
|
|
225
|
+
? (declaredManifest ? resolveNativeCargoLayout({ appRoot, config, requireManifest: true, requireLock: true, repoRoot }) : null)
|
|
226
|
+
: resolveNativeCargoLayout({
|
|
227
|
+
appRoot,
|
|
228
|
+
config,
|
|
229
|
+
requireManifest: !swift,
|
|
230
|
+
requireLock: !swift,
|
|
231
|
+
});
|
|
232
|
+
if (nativeIos && pinnedLayout && outside(repoRoot, pinnedLayout.lockPath)) throw new Error(`Cargo.lock ${pinnedLayout.lockPath} is outside the repository ${repoRoot}; a release pins only sources it can seal`);
|
|
233
|
+
const nativeLayout = nativeIos ? null : pinnedLayout;
|
|
234
|
+
const optionalIfPresent = (names) => names.map((f) => path.join(appRoot, f)).filter((f) => existsSync(f));
|
|
235
|
+
// The xcodegen spec is an input whenever iosPackage names one (a declared-but-missing spec is refused below).
|
|
236
|
+
// Only native iOS mode: an iOS app with src-tauri keeps exactly its pre-existing input set.
|
|
237
|
+
const iosProjectSpec = nativeIos && typeof iosSpec?.projectSpec === "string" && iosSpec.projectSpec ? [path.resolve(appRoot, iosSpec.projectSpec)] : [];
|
|
238
|
+
// Reviewed Swift macro/plugin trust (iosPackage.trustedSwiftMacros / trustedSwiftPlugins): the Package.resolved holding the pins is a
|
|
239
|
+
// required, hashed, git-tracked release input on both iOS paths (native and src-tauri). Without a list nothing is added.
|
|
240
|
+
const packageResolvedInput = iosTrustPackageResolved(iosSpec);
|
|
241
|
+
if (packageResolvedInput && outside(repoRoot, path.resolve(appRoot, packageResolvedInput))) throw new Error(`Package.resolved ${packageResolvedInput} is outside the repository ${repoRoot}; a release pins only sources it can seal`);
|
|
242
|
+
const iosPackageResolved = packageResolvedInput ? [path.resolve(appRoot, packageResolvedInput)] : [];
|
|
140
243
|
const requiredInputs = [
|
|
141
244
|
configPath,
|
|
142
245
|
...(swift
|
|
143
246
|
// SwiftPM: Package.swift is required; Package.resolved and the Node manifests only when they exist.
|
|
144
|
-
? [path.join(appRoot, "Package.swift"), ...["Package.resolved", "package.json", "pnpm-lock.yaml"]
|
|
247
|
+
? [path.join(appRoot, "Package.swift"), ...optionalIfPresent(["Package.resolved", "package.json", "pnpm-lock.yaml"])]
|
|
248
|
+
: nativeIos
|
|
249
|
+
// Native iOS: package.json and pnpm-lock.yaml only when the app has them, plus the declared Cargo manifest and its lock.
|
|
250
|
+
? [...optionalIfPresent(["package.json", "pnpm-lock.yaml"]), ...(pinnedLayout ? [pinnedLayout.manifestPath, pinnedLayout.lockPath] : [])]
|
|
145
251
|
: [path.join(appRoot, "package.json"), path.join(appRoot, "pnpm-lock.yaml"), nativeLayout.manifestPath, nativeLayout.lockPath]),
|
|
252
|
+
...iosProjectSpec,
|
|
253
|
+
...iosPackageResolved,
|
|
146
254
|
...(target?.preflight?.files ?? []).map((file) => path.resolve(appRoot, expandEnv(file, env))),
|
|
147
255
|
].filter((file, index, files) => files.indexOf(file) === index);
|
|
148
256
|
for (const file of requiredInputs) {
|
|
@@ -162,6 +270,8 @@ export function resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, confi
|
|
|
162
270
|
return {
|
|
163
271
|
requiredInputs,
|
|
164
272
|
nativeLayout,
|
|
273
|
+
// The Cargo.lock a native iOS app pins (its layout is null by design); preflight checks it like any other lock.
|
|
274
|
+
cargoLockPaths: nativeIos && pinnedLayout ? [pinnedLayout.lockPath] : [],
|
|
165
275
|
buildInputs: {
|
|
166
276
|
include: configured.include.map(qualify),
|
|
167
277
|
exclude: (configured.exclude ?? []).map(qualify),
|
|
@@ -1,33 +1,132 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"owner": "rightkit-daemon::mode (D4, ratified)",
|
|
4
|
-
"forbiddenFeatures": [
|
|
4
|
+
"forbiddenFeatures": [
|
|
5
|
+
"test-mode",
|
|
6
|
+
"qa-native",
|
|
7
|
+
"dev-native",
|
|
8
|
+
"qa-control",
|
|
9
|
+
"dev-inspect",
|
|
10
|
+
"testing",
|
|
11
|
+
"test-seams",
|
|
12
|
+
"isolated-test-keychain",
|
|
13
|
+
"demo",
|
|
14
|
+
"dev-overrides",
|
|
15
|
+
"qa-probes",
|
|
16
|
+
"dev-worker-override",
|
|
17
|
+
"qa-shot",
|
|
18
|
+
"qa-tools",
|
|
19
|
+
"dev-control",
|
|
20
|
+
"bench-native",
|
|
21
|
+
"rightkit-qa/control"
|
|
22
|
+
],
|
|
5
23
|
"testModeMarker": "RIGHTKIT-TEST-MODE-COMPILED",
|
|
6
24
|
"denyNames": [
|
|
7
|
-
{
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
{
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
{
|
|
32
|
-
|
|
25
|
+
{
|
|
26
|
+
"name": "RIGHTKIT_QA_NATIVE",
|
|
27
|
+
"class": "S1"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "RIGHTKIT_QA_HIDDEN",
|
|
31
|
+
"class": "S1"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "RIGHTKIT_QA_DATA_DIR",
|
|
35
|
+
"class": "S4"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "RIGHTKIT_QA_BACKGROUND",
|
|
39
|
+
"class": "S1"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "RIGHTKIT_UNDER_TEST",
|
|
43
|
+
"class": "S1"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "RIGHTKIT_SUITE_ROOT",
|
|
47
|
+
"class": "S4"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "RIGHT_SUITE_DIR",
|
|
51
|
+
"class": "S4"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"name": "ORTHIC_SHARED_DIR",
|
|
55
|
+
"class": "S4"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "RIGHTKIT_CONTROL_SERVICE",
|
|
59
|
+
"class": "S3"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "HEARDRIGHT_ENGINE_TEST_MODE",
|
|
63
|
+
"class": "S1"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "HR_APP_DATA_DIR",
|
|
67
|
+
"class": "S4"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "MAILRIGHT_DATA_DIR",
|
|
71
|
+
"class": "S4"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"name": "MAILRIGHT_EMBED_MODEL_DIR",
|
|
75
|
+
"class": "S3"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"name": "MAILRIGHT_ONNXRUNTIME_DYLIB",
|
|
79
|
+
"class": "S3"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "CUTRIGHT_DATA_DIR",
|
|
83
|
+
"class": "S4"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "GENRIGHT_QA",
|
|
87
|
+
"class": "S1"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "GENRIGHT_APP_DATA",
|
|
91
|
+
"class": "S4"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"name": "GENRIGHT_SD_SERVER",
|
|
95
|
+
"class": "S3"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"name": "CODERIGHT_HOME",
|
|
99
|
+
"class": "S4"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"name": "POSTRIGHT_ROLE=daemon-dev",
|
|
103
|
+
"class": "S1"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"name": "RIGHTAPP_ENGINE_BIN",
|
|
107
|
+
"class": "S3"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "LIGHTCRAFT_SAM3_DIR",
|
|
111
|
+
"class": "S3"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "LIGHTCRAFT_DEVICE_ROOTS",
|
|
115
|
+
"class": "S3"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "--qa",
|
|
119
|
+
"class": "S1"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": "--demo",
|
|
123
|
+
"class": "S1"
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
"conditionalForbiddenFeatures": {
|
|
127
|
+
"content-only": [
|
|
128
|
+
"devtools",
|
|
129
|
+
"content-devtools"
|
|
130
|
+
]
|
|
131
|
+
}
|
|
33
132
|
}
|
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;
|
|
@@ -191,7 +193,7 @@ export function inventoryDenyNames(inventoryText) {
|
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
// Findings for a build command (`--features test-mode`, `-F`, `--all-features`, ...). Uses the existing tokenizer.
|
|
194
|
-
export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeatures } = {}) {
|
|
196
|
+
export function commandSwitchFindings(argv, { forbidden = [...REGISTRY.forbiddenFeatures, ...REGISTRY.conditionalForbiddenFeatures["content-only"]], allowDevtools = false } = {}) {
|
|
195
197
|
const deny = new Set(forbidden);
|
|
196
198
|
const tokens = argv;
|
|
197
199
|
const enabled = []; let all = false;
|
|
@@ -206,7 +208,8 @@ export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeat
|
|
|
206
208
|
const findings = [];
|
|
207
209
|
for (const item of enabled) {
|
|
208
210
|
const name = item.includes("/") ? item.slice(item.lastIndexOf("/") + 1) : item;
|
|
209
|
-
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}\`` });
|
|
210
213
|
}
|
|
211
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" });
|
|
212
215
|
return findings;
|
|
@@ -218,7 +221,7 @@ export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeat
|
|
|
218
221
|
export function parseInventory(text, label = "switches.json") {
|
|
219
222
|
const issues = [];
|
|
220
223
|
let doc;
|
|
221
|
-
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})` }] }; }
|
|
222
225
|
const bad = (reason) => issues.push({ layer: "inventory", rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${reason}` });
|
|
223
226
|
if (doc?.schema !== 1) bad("`schema` must be 1");
|
|
224
227
|
if (typeof doc?.app !== "string" || !doc.app) bad("`app` must be a non-empty string");
|
|
@@ -233,7 +236,7 @@ export function parseInventory(text, label = "switches.json") {
|
|
|
233
236
|
if (!CHANNELS.has(entry.channel)) bad(`${where}: \`channel\` must be one of ${[...CHANNELS].join(", ")}`);
|
|
234
237
|
if (FORBIDDEN_CLASSES.has(entry.class)) {
|
|
235
238
|
if (typeof entry.compiledOut !== "string" || !COMPILED_OUT.test(entry.compiledOut)) {
|
|
236
|
-
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)` });
|
|
237
240
|
}
|
|
238
241
|
} else if (entry.class === "S6") {
|
|
239
242
|
if (!["bool", "int", "enum"].includes(entry.valueType)) bad(`${where}: class S6 needs a bounded \`valueType\` (bool, int or enum)`);
|
|
@@ -245,13 +248,20 @@ export function parseInventory(text, label = "switches.json") {
|
|
|
245
248
|
}
|
|
246
249
|
|
|
247
250
|
const SOURCE_EXT = new Set([".rs", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".jsx", ".vue", ".svelte", ".swift", ".c", ".cc", ".cpp", ".h", ".m", ".mm"]);
|
|
248
|
-
|
|
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);
|
|
249
256
|
const TEST_FILE = /(\.test\.|\.spec\.|_tests?\.rs$|^tests?\.rs$)/;
|
|
250
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)$/;
|
|
251
261
|
// Reads of a literal name; group 1 is the name.
|
|
252
262
|
const LITERAL_READS = [
|
|
253
263
|
/\b(?:std::)?env::var(?:_os)?\(\s*"([^"]+)"\s*\)/g,
|
|
254
|
-
|
|
264
|
+
CARGO_COMPILE_TIME,
|
|
255
265
|
/\bgetenv\(\s*"([^"]+)"\s*\)/g,
|
|
256
266
|
/process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
257
267
|
/process\.env\[\s*["']([^"']+)["']\s*\]/g,
|
|
@@ -287,7 +297,7 @@ function* walkSource(root) {
|
|
|
287
297
|
for (const entry of entries) {
|
|
288
298
|
const full = path.join(dir, entry.name);
|
|
289
299
|
if (entry.isSymbolicLink()) continue;
|
|
290
|
-
if (entry.isDirectory()) { if (!
|
|
300
|
+
if (entry.isDirectory()) { if (!skipDir(entry.name)) stack.push(full); continue; }
|
|
291
301
|
if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name)) && !TEST_FILE.test(entry.name)) yield full;
|
|
292
302
|
}
|
|
293
303
|
}
|
|
@@ -296,7 +306,7 @@ function* walkSource(root) {
|
|
|
296
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; }
|
|
297
307
|
|
|
298
308
|
// inventoryText: contents of switches.json. Returns findings only.
|
|
299
|
-
export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel = "switches.json", extraDeny = [] }) {
|
|
309
|
+
export function sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, inventoryLabel = "switches.json", extraDeny = [] }) {
|
|
300
310
|
const { doc, issues } = parseInventory(inventoryText, inventoryLabel);
|
|
301
311
|
const findings = [...issues];
|
|
302
312
|
if (!doc || !Array.isArray(doc.switches)) return findings;
|
|
@@ -304,7 +314,9 @@ export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel
|
|
|
304
314
|
const dynamicAllowed = new Set((doc.dynamicReads ?? []).map((d) => path.normalize(d.file)));
|
|
305
315
|
const reads = new Set();
|
|
306
316
|
const denyFlags = [...REGISTRY.denyNames, ...extraDeny.map((n) => ({ name: typeof n === "string" ? n : n.name }))].map((d) => d.name).filter((n) => n.startsWith("-"));
|
|
307
|
-
|
|
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)])) {
|
|
308
320
|
let text;
|
|
309
321
|
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
310
322
|
const rel = path.relative(sourceRoot, file);
|
|
@@ -314,6 +326,9 @@ export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel
|
|
|
314
326
|
for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
|
|
315
327
|
const name = m[1];
|
|
316
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;
|
|
317
332
|
reads.add(name);
|
|
318
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}` });
|
|
319
334
|
}
|
|
@@ -430,18 +445,18 @@ export function scanSwitchPaths(paths, { denyMap = denyNamesFor() } = {}) {
|
|
|
430
445
|
// Every layer is optional input; a layer given no input is skipped, except that `requireBinary` fails a scan that
|
|
431
446
|
// found no native binary. `bypass: ["feature-closure"]` exists only for the negative fixtures that prove the binary
|
|
432
447
|
// layer independently; release.mjs never passes it.
|
|
433
|
-
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 } = {}) {
|
|
434
449
|
const findings = [];
|
|
435
450
|
const bypassed = new Set(bypass);
|
|
436
451
|
const denyMap = denyNamesFor({ appForbid: [...appForbid, ...(inventoryText != null ? inventoryDenyNames(inventoryText) : [])] });
|
|
437
452
|
if (!bypassed.has("feature-closure")) {
|
|
438
453
|
if (treeText != null) {
|
|
439
|
-
findings.push(...featureClosureFindings(treeText));
|
|
454
|
+
findings.push(...featureClosureFindings(treeText, { allowDevtools: devtools?.mode === "content-only" }));
|
|
440
455
|
findings.push(...remoteServiceFindings(treeText, { remoteHost }));
|
|
441
456
|
}
|
|
442
|
-
for (const argv of commands) findings.push(...commandSwitchFindings(argv));
|
|
457
|
+
for (const argv of commands) findings.push(...commandSwitchFindings(argv, { allowDevtools: devtools?.mode === "content-only" }));
|
|
443
458
|
}
|
|
444
|
-
if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, inventoryText, extraDeny: appForbid }));
|
|
459
|
+
if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, extraDeny: appForbid }));
|
|
445
460
|
let receipt = { files: [], binaries: 0 };
|
|
446
461
|
if (binaryPaths.length) {
|
|
447
462
|
const scanned = scanSwitchPaths(binaryPaths, { denyMap });
|
|
@@ -460,3 +475,16 @@ export function assertNoReleaseSwitches(input, label = "release") {
|
|
|
460
475
|
+ "Build release and update tiers without the `test-mode` feature, and register every environment read in the app's switches.json.",
|
|
461
476
|
);
|
|
462
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
|
+
}
|