@rightkit/release 0.6.3 → 0.6.5
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 +67 -5
- package/admission-files.mjs +279 -0
- package/admission-host.mjs +403 -0
- package/admission.mjs +146 -0
- package/app-check.mjs +869 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +68 -29
- package/build-release.mjs +81 -21
- package/cli/right-release.mjs +39 -5
- package/deterministic-scans.mjs +42 -15
- package/development.mjs +3 -2
- package/hosted-workflow-policy.mjs +111 -0
- package/inventory-check.mjs +12 -1
- package/ios-dev.mjs +2 -2
- package/ios-host.mjs +7 -7
- package/ios-package-recipe.mjs +2 -2
- package/local-publish.mjs +1 -1
- package/local-qualify.mjs +3 -3
- package/local-runtime.mjs +1 -1
- package/ownership-dependency-allowlist.json +2 -0
- package/package.json +5 -3
- package/pipeline-admission.mjs +5 -4
- package/pipeline-receipts.mjs +302 -302
- package/pipeline-scan.mjs +3 -3
- package/pipeline-tools.mjs +175 -171
- package/prebuild-gates.mjs +191 -0
- package/progress-control.mjs +1 -1
- package/publish-update.mjs +4 -3
- package/qa-admission-policy.json +5 -0
- package/release-config.mjs +353 -330
- package/release-qa-audit.mjs +37 -11
- package/release-qa-command.mjs +46 -5
- package/release-qa-fixtures.mjs +127 -0
- package/release-qa-floor.json +6 -1
- package/release-qa-hardening.mjs +173 -0
- package/release-qa-policy.mjs +168 -0
- package/release-qa-receipt.mjs +21 -1
- package/release-switch-scan.mjs +137 -6
- package/release.mjs +333 -231
- package/rightapps-register.mjs +3 -1
- package/rightkit-drift.mjs +1 -1
- package/rightkit-ownership.json +15 -5
- package/rightkit-versions.json +50 -37
- package/stage-sidecar.mjs +17 -15
- package/suite-api-literal.mjs +109 -0
- package/suite-api.generated.mjs +78 -0
- package/swap-helper-bundle.mjs +2 -2
- package/win-package-recipe.mjs +7 -4
package/pipeline-scan.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import path from "node:path";
|
|
|
16
16
|
import { admitHookString, admitTauriHook } from "./pipeline-admission.mjs";
|
|
17
17
|
import { loadGeneratorRegistry, makeGeneratorAdmitter } from "./pipeline-generators.mjs";
|
|
18
18
|
import { ownershipOfCode, ruleOfCode, validateConfigPipeline } from "./pipeline-config.mjs";
|
|
19
|
-
import { classifyAction, dispatchesRightKit, scanTextForPipelineTools, segmentTokens, splitSegments } from "./pipeline-tools.mjs";
|
|
19
|
+
import { MANAGED_CLIENT_NAMES, classifyAction, dispatchesRightKit, scanTextForPipelineTools, segmentTokens, splitSegments } from "./pipeline-tools.mjs";
|
|
20
20
|
import { LEGACY_RELEASE_CONFIG_FILE, RELEASE_CONFIG_FILE, loadReleaseConfigSync } from "./release-config.mjs";
|
|
21
21
|
import { PIPELINE_DISPATCH_SUBCOMMANDS } from "./pipeline-admission.mjs";
|
|
22
22
|
|
|
@@ -252,7 +252,7 @@ export function scanApp({ appRoot, registry, fs = defaultFs } = {}) {
|
|
|
252
252
|
const spawn = /Command::new\(\s*"([^"]+)"/.exec(lineText);
|
|
253
253
|
if (!spawn) return;
|
|
254
254
|
const name = path.basename(spawn[1]).toLowerCase().replace(/\.(?:exe|cmd|bat)$/, "");
|
|
255
|
-
if (name
|
|
255
|
+
if (MANAGED_CLIENT_NAMES.includes(name) || name === "right-release") add({ code: "R2-build-rs-dispatch", file: label, line: i + 1, stage: "hook", ownership: "app-owned", message: `${label} starts ${name} from build.rs; a generator cannot dispatch another pipeline` });
|
|
256
256
|
else if (["node", "python", "python3", "py", "bash", "sh", "zsh", "pwsh", "powershell", "cmd", "pnpm", "npm", "npx", "yarn", "deno", "bun", "ruby", "perl"].includes(name)) add({ code: "R4-build-rs-generator", file: label, line: i + 1, stage: "generator", ownership: "unknown", message: `${label} runs ${name} from build.rs; a pipeline generator must be an admitted shared step (pipeline-generators.json), not app code` });
|
|
257
257
|
});
|
|
258
258
|
}
|
|
@@ -269,7 +269,7 @@ export function scanApp({ appRoot, registry, fs = defaultFs } = {}) {
|
|
|
269
269
|
}
|
|
270
270
|
for (const segment of body.script.split(/\r?\n/).flatMap(splitSegments)) {
|
|
271
271
|
const tokens = segmentTokens(segment);
|
|
272
|
-
if (dispatchesRightKit(tokens) && (tokens[0]
|
|
272
|
+
if (dispatchesRightKit(tokens) && (MANAGED_CLIENT_NAMES.includes(tokens[0]) || PIPELINE_DISPATCH_SUBCOMMANDS.has(tokens[1]))) add({ code: "R2-xcode-script-dispatch", file: label, line: kind === "xcode" ? body.line : 1, stage: "hook", ownership: "app-owned", message: `${label} script phase starts a RightKit pipeline run; a hook cannot dispatch another pipeline` });
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
}
|
package/pipeline-tools.mjs
CHANGED
|
@@ -1,171 +1,175 @@
|
|
|
1
|
-
// M24 R3: the build, sign, notarize, package and upload tool inventory, and the text scanner that finds those tools in a pipeline
|
|
2
|
-
// context (an app script, a package hook, a workflow step, a framework hook, a build.rs). Pure functions: no file access.
|
|
3
|
-
//
|
|
4
|
-
// Scope (Astra, M24 R3): the tools below are tracked when a pipeline context or a helper reachable from one runs them. Read-only
|
|
5
|
-
// forms (`gh release view`, `codesign --verify`, `signtool verify`, `stapler validate`, `aws s3 ls`, ...) are not an upload or a
|
|
6
|
-
// signature and are never reported. This is a static text scan: it proves nothing about what a native tool does, and ordinary
|
|
7
|
-
// product runtime code (src/, crates) is never scanned by it, so there is no blanket ban on a product that happens to sign
|
|
8
|
-
// or package something at runtime.
|
|
9
|
-
|
|
10
|
-
/** The tool ids R3 inventories, with the pipeline stage each one acts in. */
|
|
11
|
-
export const PIPELINE_TOOL_IDS = Object.freeze([
|
|
12
|
-
"codesign", "notarytool", "altool", "stapler", "signtool", "azuresigntool", "productbuild", "pkgbuild", "hdiutil", "create-dmg",
|
|
13
|
-
"makensis", "iscc", "wix", "wrangler", "rclone", "aws-s3", "gh-release", "cargo-publish", "npm-publish", "pnpm-publish",
|
|
14
|
-
"tauri-build", "xcode-archive",
|
|
15
|
-
]);
|
|
16
|
-
|
|
17
|
-
const READ_ONLY = (list) => (rest) => list.includes(String(rest[0] ?? "").toLowerCase());
|
|
18
|
-
|
|
19
|
-
/** name (lowercased basename without a script or exe extension) -> classifier of the remaining argument tokens. */
|
|
20
|
-
const RULES = [
|
|
21
|
-
{ id: "codesign", stage: "sign", names: ["codesign"], flagged: (rest) => {
|
|
22
|
-
const flags = rest.filter((arg) => arg.startsWith("-"));
|
|
23
|
-
const signs = flags.some((arg) => /^(?:-s|--sign|-f|--force|--remove-signature)$/.test(arg));
|
|
24
|
-
const readsOnly = flags.some((arg) => /^(?:--verify|-v+|-d+v*|--display|--test-requirement|-r+-?|-h|--help)$/.test(arg));
|
|
25
|
-
return signs || !readsOnly;
|
|
26
|
-
} },
|
|
27
|
-
{ id: "notarytool", stage: "notarize", names: ["notarytool"], flagged: (rest) => !READ_ONLY(["info", "log", "history", "--help", "--version", "help"])(rest) },
|
|
28
|
-
{ id: "altool", stage: "notarize", names: ["altool"], flagged: () => true },
|
|
29
|
-
{ id: "stapler", stage: "notarize", names: ["stapler"], flagged: (rest) => !READ_ONLY(["validate", "--help", "help"])(rest) },
|
|
30
|
-
{ id: "signtool", stage: "sign", names: ["signtool"], flagged: (rest) => !READ_ONLY(["verify", "/?", "-?", "--help"])(rest) },
|
|
31
|
-
{ id: "azuresigntool", stage: "sign", names: ["azuresigntool", "trusted-signing-cli"], flagged: () => true },
|
|
32
|
-
{ id: "productbuild", stage: "package", names: ["productbuild", "productsign"], flagged: () => true },
|
|
33
|
-
{ id: "pkgbuild", stage: "package", names: ["pkgbuild"], flagged: () => true },
|
|
34
|
-
{ id: "hdiutil", stage: "package", names: ["hdiutil"], flagged: (rest) => READ_ONLY(["create", "convert", "makehybrid", "resize", "compact", "burn"])(rest) },
|
|
35
|
-
{ id: "create-dmg", stage: "package", names: ["create-dmg"], flagged: () => true },
|
|
36
|
-
{ id: "makensis", stage: "package", names: ["makensis"], flagged: () => true },
|
|
37
|
-
{ id: "iscc", stage: "package", names: ["iscc"], flagged: () => true },
|
|
38
|
-
{ id: "wix", stage: "package", names: ["candle"], flagged: () => true },
|
|
39
|
-
{ id: "wix", stage: "package", names: ["wix"], flagged: (rest) => READ_ONLY(["build", "msi", "burn", "convert", "extension"])(rest) },
|
|
40
|
-
{ id: "wix", stage: "package", names: ["light"], flagged: (rest) => rest.some((arg) => /\.(?:wixobj|wxs|wixlib)$/i.test(arg) || /^-out$/i.test(arg)) },
|
|
41
|
-
{ id: "wrangler", stage: "upload", names: ["wrangler"], flagged: (rest) => !READ_ONLY(["--version", "-v", "whoami", "--help", "-h"])(rest) },
|
|
42
|
-
{ id: "rclone", stage: "upload", names: ["rclone"], flagged: (rest) => !READ_ONLY(["ls", "lsf", "lsd", "lsl", "lsjson", "size", "check", "version", "listremotes", "about", "cat", "--version", "--help"])(rest) },
|
|
43
|
-
{ id: "aws-s3", stage: "upload", names: ["aws"], flagged: (rest) => (rest[0] === "s3" && !["ls", "help"].includes(String(rest[1] ?? "")))
|
|
44
|
-
|| (rest[0] === "s3api" && /^(?:put-|create-multipart-upload|upload-part|complete-multipart-upload|copy-object|delete-)/.test(String(rest[1] ?? ""))) },
|
|
45
|
-
{ id: "gh-release", stage: "upload", names: ["gh"], flagged: (rest) => rest[0] === "release" && ["create", "upload", "edit", "delete", "delete-asset"].includes(String(rest[1] ?? "")) },
|
|
46
|
-
{ id: "cargo-publish", stage: "upload", names: ["cargo"], flagged: (rest) => rest[0] === "publish" || (rest[0] === "tauri" && ["build", "bundle", "signer"].includes(String(rest[1] ?? ""))) },
|
|
47
|
-
{ id: "tauri-build", stage: "build", names: ["cargo-tauri"], flagged: (rest) => ["build", "bundle", "signer"].includes(String(rest[0] ?? "")) },
|
|
48
|
-
{ id: "npm-publish", stage: "upload", names: ["npm"], flagged: (rest) => rest[0] === "publish" },
|
|
49
|
-
{ id: "pnpm-publish", stage: "upload", names: ["pnpm"], flagged: (rest) => rest[0] === "publish" },
|
|
50
|
-
{ id: "npm-publish", stage: "upload", names: ["yarn"], flagged: (rest) => rest[0] === "publish" || (rest[0] === "npm" && rest[1] === "publish") },
|
|
51
|
-
{ id: "tauri-build", stage: "build", names: ["tauri"], flagged: (rest) => ["build", "bundle", "signer"].includes(String(rest[0] ?? "")) },
|
|
52
|
-
{ id: "xcode-archive", stage: "package", names: ["xcodebuild"], flagged: (rest) => rest.some((arg) => /^(?:archive|-exportArchive|-archivePath|-exportOptionsPlist)$/.test(arg)) },
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
/** `
|
|
56
|
-
const MANAGED_CLIENT_RULE = { id: "cargo-publish", stage: "upload" };
|
|
57
|
-
|
|
58
|
-
/** Wrapper words that run another command: skipped so the tool behind them is the one classified. */
|
|
59
|
-
const WRAPPERS = new Set(["sudo", "env", "time", "command", "exec", "call", "start", "nohup", "xcrun", "npx", "corepack", "pwsh", "powershell", "bash", "sh", "zsh", "cmd", "node", "python", "python3", "py"]);
|
|
60
|
-
|
|
61
|
-
const stripQuotes = (token) => String(token).replace(/^[\s"'`@&]+|[\s"'`,;]+$/g, "");
|
|
62
|
-
export const commandBaseName = (token) => stripQuotes(token).split(/[\\/]/).pop().toLowerCase().replace(/\.(?:exe|cmd|bat|ps1|sh|com|command)$/i, "");
|
|
63
|
-
|
|
64
|
-
/** Split one script line into command segments at the shell operators. */
|
|
65
|
-
export function splitSegments(line) {
|
|
66
|
-
return String(line).split(/&&|\|\||\$\(|[;|&`()]/).map((part) => part.trim()).filter(Boolean);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Tokens of one segment after the leading VAR=value assignments and wrapper words. */
|
|
70
|
-
export function segmentTokens(segment) {
|
|
71
|
-
let tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
72
|
-
tokens = tokens.map((token) => token.replace(/^["']|["']$/g, ""));
|
|
73
|
-
while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens = tokens.slice(1);
|
|
74
|
-
for (let guard = 0; guard < 8 && tokens.length; guard += 1) {
|
|
75
|
-
const name = commandBaseName(tokens[0]);
|
|
76
|
-
if (name === "pnpm" && (tokens[1] === "exec" || tokens[1] === "dlx")) { tokens = tokens.slice(2); continue; }
|
|
77
|
-
if (name === "npm" && tokens[1] === "exec") { const dash = tokens.indexOf("--"); tokens = dash >= 0 ? tokens.slice(dash + 1) : tokens.slice(2); continue; }
|
|
78
|
-
if (WRAPPERS.has(name) && tokens.length > 1) {
|
|
79
|
-
// Interpreters and shells run a script, not another tool, unless they take -c/-Command text: keep scanning the rest.
|
|
80
|
-
tokens = tokens.slice(1).filter((token, index) => !(index === 0 && /^-+(?:c|command|noprofile|nologo|noninteractive|executionpolicy|file|f|e|s)$/i.test(token)));
|
|
81
|
-
// `bash -c "tool args"` and `powershell -Command "tool args"`: the quoted text is the command line.
|
|
82
|
-
if (tokens.length && /\s/.test(tokens[0])) tokens = [...tokens[0].trim().split(/\s+/), ...tokens.slice(1)];
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
break;
|
|
86
|
-
}
|
|
87
|
-
return tokens;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
{ pattern: /^
|
|
160
|
-
{ pattern: /^
|
|
161
|
-
{ pattern: /^
|
|
162
|
-
{ pattern: /^
|
|
163
|
-
{ pattern: /^
|
|
164
|
-
{ pattern: /^
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
1
|
+
// M24 R3: the build, sign, notarize, package and upload tool inventory, and the text scanner that finds those tools in a pipeline
|
|
2
|
+
// context (an app script, a package hook, a workflow step, a framework hook, a build.rs). Pure functions: no file access.
|
|
3
|
+
//
|
|
4
|
+
// Scope (Astra, M24 R3): the tools below are tracked when a pipeline context or a helper reachable from one runs them. Read-only
|
|
5
|
+
// forms (`gh release view`, `codesign --verify`, `signtool verify`, `stapler validate`, `aws s3 ls`, ...) are not an upload or a
|
|
6
|
+
// signature and are never reported. This is a static text scan: it proves nothing about what a native tool does, and ordinary
|
|
7
|
+
// product runtime code (src/, crates) is never scanned by it, so there is no blanket ban on a product that happens to sign
|
|
8
|
+
// or package something at runtime.
|
|
9
|
+
|
|
10
|
+
/** The tool ids R3 inventories, with the pipeline stage each one acts in. */
|
|
11
|
+
export const PIPELINE_TOOL_IDS = Object.freeze([
|
|
12
|
+
"codesign", "notarytool", "altool", "stapler", "signtool", "azuresigntool", "productbuild", "pkgbuild", "hdiutil", "create-dmg",
|
|
13
|
+
"makensis", "iscc", "wix", "wrangler", "rclone", "aws-s3", "gh-release", "cargo-publish", "npm-publish", "pnpm-publish",
|
|
14
|
+
"tauri-build", "xcode-archive",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const READ_ONLY = (list) => (rest) => list.includes(String(rest[0] ?? "").toLowerCase());
|
|
18
|
+
|
|
19
|
+
/** name (lowercased basename without a script or exe extension) -> classifier of the remaining argument tokens. */
|
|
20
|
+
const RULES = [
|
|
21
|
+
{ id: "codesign", stage: "sign", names: ["codesign"], flagged: (rest) => {
|
|
22
|
+
const flags = rest.filter((arg) => arg.startsWith("-"));
|
|
23
|
+
const signs = flags.some((arg) => /^(?:-s|--sign|-f|--force|--remove-signature)$/.test(arg));
|
|
24
|
+
const readsOnly = flags.some((arg) => /^(?:--verify|-v+|-d+v*|--display|--test-requirement|-r+-?|-h|--help)$/.test(arg));
|
|
25
|
+
return signs || !readsOnly;
|
|
26
|
+
} },
|
|
27
|
+
{ id: "notarytool", stage: "notarize", names: ["notarytool"], flagged: (rest) => !READ_ONLY(["info", "log", "history", "--help", "--version", "help"])(rest) },
|
|
28
|
+
{ id: "altool", stage: "notarize", names: ["altool"], flagged: () => true },
|
|
29
|
+
{ id: "stapler", stage: "notarize", names: ["stapler"], flagged: (rest) => !READ_ONLY(["validate", "--help", "help"])(rest) },
|
|
30
|
+
{ id: "signtool", stage: "sign", names: ["signtool"], flagged: (rest) => !READ_ONLY(["verify", "/?", "-?", "--help"])(rest) },
|
|
31
|
+
{ id: "azuresigntool", stage: "sign", names: ["azuresigntool", "trusted-signing-cli"], flagged: () => true },
|
|
32
|
+
{ id: "productbuild", stage: "package", names: ["productbuild", "productsign"], flagged: () => true },
|
|
33
|
+
{ id: "pkgbuild", stage: "package", names: ["pkgbuild"], flagged: () => true },
|
|
34
|
+
{ id: "hdiutil", stage: "package", names: ["hdiutil"], flagged: (rest) => READ_ONLY(["create", "convert", "makehybrid", "resize", "compact", "burn"])(rest) },
|
|
35
|
+
{ id: "create-dmg", stage: "package", names: ["create-dmg"], flagged: () => true },
|
|
36
|
+
{ id: "makensis", stage: "package", names: ["makensis"], flagged: () => true },
|
|
37
|
+
{ id: "iscc", stage: "package", names: ["iscc"], flagged: () => true },
|
|
38
|
+
{ id: "wix", stage: "package", names: ["candle"], flagged: () => true },
|
|
39
|
+
{ id: "wix", stage: "package", names: ["wix"], flagged: (rest) => READ_ONLY(["build", "msi", "burn", "convert", "extension"])(rest) },
|
|
40
|
+
{ id: "wix", stage: "package", names: ["light"], flagged: (rest) => rest.some((arg) => /\.(?:wixobj|wxs|wixlib)$/i.test(arg) || /^-out$/i.test(arg)) },
|
|
41
|
+
{ id: "wrangler", stage: "upload", names: ["wrangler"], flagged: (rest) => !READ_ONLY(["--version", "-v", "whoami", "--help", "-h"])(rest) },
|
|
42
|
+
{ id: "rclone", stage: "upload", names: ["rclone"], flagged: (rest) => !READ_ONLY(["ls", "lsf", "lsd", "lsl", "lsjson", "size", "check", "version", "listremotes", "about", "cat", "--version", "--help"])(rest) },
|
|
43
|
+
{ id: "aws-s3", stage: "upload", names: ["aws"], flagged: (rest) => (rest[0] === "s3" && !["ls", "help"].includes(String(rest[1] ?? "")))
|
|
44
|
+
|| (rest[0] === "s3api" && /^(?:put-|create-multipart-upload|upload-part|complete-multipart-upload|copy-object|delete-)/.test(String(rest[1] ?? ""))) },
|
|
45
|
+
{ id: "gh-release", stage: "upload", names: ["gh"], flagged: (rest) => rest[0] === "release" && ["create", "upload", "edit", "delete", "delete-asset"].includes(String(rest[1] ?? "")) },
|
|
46
|
+
{ id: "cargo-publish", stage: "upload", names: ["cargo"], flagged: (rest) => rest[0] === "publish" || (rest[0] === "tauri" && ["build", "bundle", "signer"].includes(String(rest[1] ?? ""))) },
|
|
47
|
+
{ id: "tauri-build", stage: "build", names: ["cargo-tauri"], flagged: (rest) => ["build", "bundle", "signer"].includes(String(rest[0] ?? "")) },
|
|
48
|
+
{ id: "npm-publish", stage: "upload", names: ["npm"], flagged: (rest) => rest[0] === "publish" },
|
|
49
|
+
{ id: "pnpm-publish", stage: "upload", names: ["pnpm"], flagged: (rest) => rest[0] === "publish" },
|
|
50
|
+
{ id: "npm-publish", stage: "upload", names: ["yarn"], flagged: (rest) => rest[0] === "publish" || (rest[0] === "npm" && rest[1] === "publish") },
|
|
51
|
+
{ id: "tauri-build", stage: "build", names: ["tauri"], flagged: (rest) => ["build", "bundle", "signer"].includes(String(rest[0] ?? "")) },
|
|
52
|
+
{ id: "xcode-archive", stage: "package", names: ["xcodebuild"], flagged: (rest) => rest.some((arg) => /^(?:archive|-exportArchive|-archivePath|-exportOptionsPlist)$/.test(arg)) },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** `rustright cargo publish` reaches the same crates.io upload through the managed client. */
|
|
56
|
+
const MANAGED_CLIENT_RULE = { id: "cargo-publish", stage: "upload" };
|
|
57
|
+
|
|
58
|
+
/** Wrapper words that run another command: skipped so the tool behind them is the one classified. */
|
|
59
|
+
const WRAPPERS = new Set(["sudo", "env", "time", "command", "exec", "call", "start", "nohup", "xcrun", "npx", "corepack", "pwsh", "powershell", "bash", "sh", "zsh", "cmd", "node", "python", "python3", "py"]);
|
|
60
|
+
|
|
61
|
+
const stripQuotes = (token) => String(token).replace(/^[\s"'`@&]+|[\s"'`,;]+$/g, "");
|
|
62
|
+
export const commandBaseName = (token) => stripQuotes(token).split(/[\\/]/).pop().toLowerCase().replace(/\.(?:exe|cmd|bat|ps1|sh|com|command)$/i, "");
|
|
63
|
+
|
|
64
|
+
/** Split one script line into command segments at the shell operators. */
|
|
65
|
+
export function splitSegments(line) {
|
|
66
|
+
return String(line).split(/&&|\|\||\$\(|[;|&`()]/).map((part) => part.trim()).filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Tokens of one segment after the leading VAR=value assignments and wrapper words. */
|
|
70
|
+
export function segmentTokens(segment) {
|
|
71
|
+
let tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
72
|
+
tokens = tokens.map((token) => token.replace(/^["']|["']$/g, ""));
|
|
73
|
+
while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens = tokens.slice(1);
|
|
74
|
+
for (let guard = 0; guard < 8 && tokens.length; guard += 1) {
|
|
75
|
+
const name = commandBaseName(tokens[0]);
|
|
76
|
+
if (name === "pnpm" && (tokens[1] === "exec" || tokens[1] === "dlx")) { tokens = tokens.slice(2); continue; }
|
|
77
|
+
if (name === "npm" && tokens[1] === "exec") { const dash = tokens.indexOf("--"); tokens = dash >= 0 ? tokens.slice(dash + 1) : tokens.slice(2); continue; }
|
|
78
|
+
if (WRAPPERS.has(name) && tokens.length > 1) {
|
|
79
|
+
// Interpreters and shells run a script, not another tool, unless they take -c/-Command text: keep scanning the rest.
|
|
80
|
+
tokens = tokens.slice(1).filter((token, index) => !(index === 0 && /^-+(?:c|command|noprofile|nologo|noninteractive|executionpolicy|file|f|e|s)$/i.test(token)));
|
|
81
|
+
// `bash -c "tool args"` and `powershell -Command "tool args"`: the quoted text is the command line.
|
|
82
|
+
if (tokens.length && /\s/.test(tokens[0])) tokens = [...tokens[0].trim().split(/\s+/), ...tokens.slice(1)];
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
return tokens;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The RustRight client's names. `rightkit` is its retired name (rustright#91): never admitted (pipeline-admission.mjs refuses
|
|
91
|
+
* it, scripts/preflight.mjs retired-alias fails it), but still recognised here, so a script that uses it is classified, never missed. */
|
|
92
|
+
export const MANAGED_CLIENT_NAMES = Object.freeze(["rustright", "rightkit"]);
|
|
93
|
+
|
|
94
|
+
/** Classify one command's tokens against the R3 inventory: `{ id, stage, tool }` for a pipeline tool run in a write form, else null. */
|
|
95
|
+
export function classifyTokens(tokens) {
|
|
96
|
+
if (!tokens.length) return null;
|
|
97
|
+
const name = commandBaseName(tokens[0]);
|
|
98
|
+
const rest = tokens.slice(1);
|
|
99
|
+
if (MANAGED_CLIENT_NAMES.includes(name) && rest[0] === "cargo" && rest[1] === "publish") return { ...MANAGED_CLIENT_RULE, tool: "rustright cargo publish" };
|
|
100
|
+
for (const rule of RULES) {
|
|
101
|
+
if (rule.names.includes(name) && rule.flagged(rest)) return { id: rule.id, stage: rule.stage, tool: [name, ...rest.slice(0, 2)].join(" ").trim() };
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** True when the tokens run RightKit's own pipeline CLI or managed client (a generator must never dispatch another pipeline). */
|
|
107
|
+
export function dispatchesRightKit(tokens) {
|
|
108
|
+
if (!tokens.length) return false;
|
|
109
|
+
const name = commandBaseName(tokens[0]);
|
|
110
|
+
if (MANAGED_CLIENT_NAMES.includes(name) || name === "right-release") return true;
|
|
111
|
+
return tokens.some((token) => /(?:^|[\\/])(?:@rightkit[\\/]release[\\/]cli[\\/]right-release|right-release)(?:\.mjs)?$/i.test(token));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const YAML_RUN_PREFIX = /^\s*(?:-\s+)?(?:run|script|command)\s*:\s*[|>]?[-+]?\s*/;
|
|
115
|
+
const COMMENT_LINE = /^\s*(?:#|\/\/|::|rem\s|@rem\s)/i;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Pipeline tool matches in the text of a shell-like source (a script, a hook, a workflow). `kind`:
|
|
119
|
+
* - "shell": every line is read as a command line (sh, ps1, bat, cmd, command, yml run steps, a package.json script value);
|
|
120
|
+
* - "code": quoted tool names in a js/mjs/py/rs source (`spawnSync("codesign", ...)`), qualified by the next literals.
|
|
121
|
+
* Returns `[{ line, id, stage, tool, text }]` (1-based lines).
|
|
122
|
+
*/
|
|
123
|
+
export function scanTextForPipelineTools(text, { kind = "shell" } = {}) {
|
|
124
|
+
const found = [];
|
|
125
|
+
const lines = String(text).split(/\r?\n/);
|
|
126
|
+
if (kind === "code") {
|
|
127
|
+
const names = new Map();
|
|
128
|
+
for (const rule of RULES) for (const name of rule.names) names.set(name, rule);
|
|
129
|
+
lines.forEach((line, index) => {
|
|
130
|
+
if (COMMENT_LINE.test(line)) return;
|
|
131
|
+
for (const literal of line.matchAll(/["'`]([^"'`\n]{1,200})["'`]/g)) {
|
|
132
|
+
const tokens = literal[1].trim().split(/\s+/);
|
|
133
|
+
const first = commandBaseName(tokens[0]);
|
|
134
|
+
const rule = names.get(first) ?? (MANAGED_CLIENT_NAMES.includes(first) ? MANAGED_CLIENT_RULE : null);
|
|
135
|
+
if (!rule) continue;
|
|
136
|
+
// The arguments are the rest of this literal, then the next few literals of the same call (an argv array).
|
|
137
|
+
const tail = line.slice(literal.index + literal[0].length, literal.index + literal[0].length + 240);
|
|
138
|
+
const following = [...tail.matchAll(/["'`]([^"'`\n]{0,120})["'`]/g)].map((match) => match[1]);
|
|
139
|
+
const rest = [...tokens.slice(1), ...following].map((arg) => arg.trim()).filter(Boolean);
|
|
140
|
+
const hit = MANAGED_CLIENT_NAMES.includes(first) ? classifyTokens([first, ...rest]) : rule.flagged(rest) ? { id: rule.id, stage: rule.stage, tool: first } : null;
|
|
141
|
+
if (hit) found.push({ line: index + 1, ...hit, text: line.trim().slice(0, 160) });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
lines.forEach((line, index) => {
|
|
147
|
+
if (COMMENT_LINE.test(line)) return;
|
|
148
|
+
const body = line.replace(YAML_RUN_PREFIX, "");
|
|
149
|
+
for (const segment of splitSegments(body)) {
|
|
150
|
+
const hit = classifyTokens(segmentTokens(segment));
|
|
151
|
+
if (hit) found.push({ line: index + 1, ...hit, text: line.trim().slice(0, 160) });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return found;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** GitHub Actions that build, sign or upload on their own (R3: the same tools behind an action). */
|
|
158
|
+
export const PIPELINE_ACTIONS = Object.freeze([
|
|
159
|
+
{ pattern: /^tauri-apps\/tauri-action(?:@|$)/i, id: "tauri-build", stage: "build" },
|
|
160
|
+
{ pattern: /^apple-actions\//i, id: "codesign", stage: "sign" },
|
|
161
|
+
{ pattern: /^azure\/(?:trusted-signing-action|artifact-signing-action)(?:@|$)/i, id: "azuresigntool", stage: "sign" },
|
|
162
|
+
{ pattern: /^cloudflare\/wrangler-action(?:@|$)/i, id: "wrangler", stage: "upload" },
|
|
163
|
+
{ pattern: /^softprops\/action-gh-release(?:@|$)/i, id: "gh-release", stage: "upload" },
|
|
164
|
+
{ pattern: /^ncipollo\/release-action(?:@|$)/i, id: "gh-release", stage: "upload" },
|
|
165
|
+
{ pattern: /^svenstaro\/upload-release-action(?:@|$)/i, id: "gh-release", stage: "upload" },
|
|
166
|
+
{ pattern: /^actions\/upload-release-asset(?:@|$)/i, id: "gh-release", stage: "upload" },
|
|
167
|
+
{ pattern: /^shogo82148\/actions-upload-release-asset(?:@|$)/i, id: "gh-release", stage: "upload" },
|
|
168
|
+
{ pattern: /^jakejarvis\/s3-sync-action(?:@|$)/i, id: "aws-s3", stage: "upload" },
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
export function classifyAction(uses) {
|
|
172
|
+
const value = String(uses).trim().replace(/^["']|["']$/g, "");
|
|
173
|
+
for (const entry of PIPELINE_ACTIONS) if (entry.pattern.test(value)) return { id: entry.id, stage: entry.stage, tool: value };
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Source-level release gates that run before a build starts. The build (release.mjs) and `doctor --gates` call the
|
|
2
|
+
// same function, so the doctor can never pass a config the build refuses. Gate order is part of the contract.
|
|
3
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "./background-process.mjs";
|
|
6
|
+
import { assertReleaseDependencies } from "./release-dependencies.mjs";
|
|
7
|
+
import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
|
|
8
|
+
import { expandRecipeBuildCommands } from "./recipe-build.mjs";
|
|
9
|
+
import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure, shippedRustTriples, validateNonShipped } from "./release-switch-scan.mjs";
|
|
10
|
+
import { iosOwnershipScope } from "./ownership-scope.mjs";
|
|
11
|
+
import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
|
|
12
|
+
import { releaseSwitchInputs } from "./ios-build-context.mjs";
|
|
13
|
+
import { productionModeProblems } from "./production-mode.mjs";
|
|
14
|
+
|
|
15
|
+
function commandList(value) {
|
|
16
|
+
if (value == null) return [];
|
|
17
|
+
return Array.isArray(value) ? value : [value];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function git(cwd, runArgs) {
|
|
21
|
+
const result = spawnSync("git", runArgs, { cwd, encoding: "utf8", windowsHide: true });
|
|
22
|
+
if (result.status !== 0) throw new Error(`git ${runArgs.join(" ")} failed: ${(result.stderr ?? result.stdout ?? "").trim()}`);
|
|
23
|
+
return result.stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const BUILTIN_ERRORS = new Set([TypeError, RangeError, ReferenceError, SyntaxError, EvalError, URIError]);
|
|
27
|
+
|
|
28
|
+
// The text of a thrown value as a finding. A gate refuses by throwing an ordinary Error (its message is the exact refusal
|
|
29
|
+
// text); a built-in runtime error (TypeError, ...) is a defect in a gate or its input, so the finding says which gate threw.
|
|
30
|
+
function thrownText(name, error) {
|
|
31
|
+
if (error instanceof Error) return BUILTIN_ERRORS.has(error.constructor) ? `gate ${name} threw ${error.name}: ${error.message}` : error.message;
|
|
32
|
+
return `gate ${name} threw a non-Error value: ${String(error)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run the pre-build source gates in build order and collect EVERY independent finding (never stop at the first).
|
|
37
|
+
* A gate whose input comes from an earlier gate that failed is not run on that bad input: it is reported as
|
|
38
|
+
* `BLOCKED by <gate>`. An exception inside a gate is a finding, never a crash and never a pass.
|
|
39
|
+
*
|
|
40
|
+
* Dependencies (a gate lists the gates whose output it consumes):
|
|
41
|
+
* switch-inputs -
|
|
42
|
+
* devtools-allowance -
|
|
43
|
+
* build-features switch-inputs, devtools-allowance (commands and cargo manifest from the scan inputs)
|
|
44
|
+
* production-mode -
|
|
45
|
+
* closure-commands switch-inputs (the recipe commands every closure gate reads)
|
|
46
|
+
* release-dependencies closure-commands
|
|
47
|
+
* feature-closure closure-commands (strict only: a dry run only reports the closure problem)
|
|
48
|
+
* switch-inventory - (reads switches.json; absent is not a failure here)
|
|
49
|
+
* source-switch-roots closure-commands, feature-closure when strict (ios: git + ownership scope + the closure's crates; others: releaseSwitches.nonShipped)
|
|
50
|
+
* source-switch-prepare source-switch-roots (ios prepare directories must sit inside the scan roots)
|
|
51
|
+
* switch-inventory-missing - (strict only)
|
|
52
|
+
* release-switches closure-commands, devtools-allowance, switch-inventory, source-switch-roots, and feature-closure when strict
|
|
53
|
+
* `detail` is the exact refusal text the build prints (after "right-release: ").
|
|
54
|
+
* `strict` is true for a real release build (a missing switches.json or an unrunnable feature closure fails);
|
|
55
|
+
* a dry run passes false and only reports the closure problem.
|
|
56
|
+
* Also returns `devtoolsAllowance` and `inventoryText` (the switches.json text or null) for the later binary scans.
|
|
57
|
+
* @returns {{ findings: { name: string, detail: string }[], treeText: string | undefined, devtoolsAllowance: object, inventoryText: string | null }}
|
|
58
|
+
*/
|
|
59
|
+
export function evaluatePrebuildGates({ root, config, target, platform, iosSpec, nativeIos, nativeAssembly, nativeFinalizer, strict = true }) {
|
|
60
|
+
const state = { devtoolsAllowance: { mode: "denied" }, inventoryText: null };
|
|
61
|
+
const findings = [];
|
|
62
|
+
const failed = new Set();
|
|
63
|
+
// Run one gate: `after` names the gates it depends on. Returns { ok, value }; a blocked or failed gate is a finding.
|
|
64
|
+
const gate = (name, after, run) => {
|
|
65
|
+
const blocker = after.find((dependency) => failed.has(dependency));
|
|
66
|
+
if (blocker) {
|
|
67
|
+
failed.add(name);
|
|
68
|
+
findings.push({ name, detail: `BLOCKED by ${blocker}` });
|
|
69
|
+
return { ok: false };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return { ok: true, value: run() };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
failed.add(name);
|
|
75
|
+
findings.push({ name, detail: thrownText(name, error) });
|
|
76
|
+
return { ok: false };
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// 1. what the release-switch scan can see: the Rust a build compiles is checked from the manifest it compiles.
|
|
81
|
+
const inputs = gate("switch-inputs", [], () => {
|
|
82
|
+
const switchInputs = releaseSwitchInputs({ appRoot: root, config, target, prepare: iosSpec?.prepare ?? [], nativeIos });
|
|
83
|
+
if (switchInputs.problems.length) throw new Error(`${config.app ?? "app"} ios release-switch scan cannot see what is built:\n- ${switchInputs.problems.join("\n- ")}`);
|
|
84
|
+
return switchInputs;
|
|
85
|
+
});
|
|
86
|
+
const switchInputs = inputs.value;
|
|
87
|
+
// 2. devtools allowance and forbidden build features.
|
|
88
|
+
gate("devtools-allowance", [], () => {
|
|
89
|
+
state.devtoolsAllowance = resolveDevtoolsAllowance({ config, root });
|
|
90
|
+
if (state.devtoolsAllowance.mode === "content-only") console.log(`right-release: devtools allowance recorded: ${JSON.stringify(devtoolsEvidence(state.devtoolsAllowance))}`);
|
|
91
|
+
});
|
|
92
|
+
gate("build-features", ["switch-inputs", "devtools-allowance"], () => {
|
|
93
|
+
assertNoForbiddenBuildFeatures({ root, tier: "release/update", devtools: state.devtoolsAllowance, cargoManifest: switchInputs.cargoManifest, manifest: config.releaseSwitches?.manifest, commands: [
|
|
94
|
+
...switchInputs.commands,
|
|
95
|
+
{ label: "target.prePackage", command: target.prePackage },
|
|
96
|
+
{ label: "target.package", command: target.package },
|
|
97
|
+
{ label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
|
|
98
|
+
{ label: "nativeAssembly.finalizer", command: nativeFinalizer },
|
|
99
|
+
...commandList(target.postSign).map((command, index) => ({ label: Array.isArray(target.postSign) ? `target.postSign[${index}]` : "target.postSign", command })),
|
|
100
|
+
{ label: "target.gradle", command: target.gradle },
|
|
101
|
+
] });
|
|
102
|
+
});
|
|
103
|
+
// 3. M12: the frontend build resolves in production mode only.
|
|
104
|
+
gate("production-mode", [], () => {
|
|
105
|
+
const modeProblems = productionModeProblems({ appRoot: root });
|
|
106
|
+
if (modeProblems.length) throw new Error(`${config.app ?? "app"} release frontend is not production-only:\n- ${modeProblems.join("\n- ")}`);
|
|
107
|
+
});
|
|
108
|
+
// 4. D4 (M21/M23): dependencies, the resolved feature graph and the source switch inventory.
|
|
109
|
+
let releaseSwitchInventoryText = null;
|
|
110
|
+
const inventory = gate("switch-inventory", [], () => {
|
|
111
|
+
const inventoryPath = path.resolve(root, config.releaseSwitches?.inventory ?? "switches.json");
|
|
112
|
+
return { inventoryPath, text: existsSync(inventoryPath) ? readFileSync(inventoryPath, "utf8") : null };
|
|
113
|
+
});
|
|
114
|
+
const releaseSwitchInventoryPath = inventory.ok ? inventory.value.inventoryPath : path.resolve(root, "switches.json");
|
|
115
|
+
if (inventory.ok) releaseSwitchInventoryText = state.inventoryText = inventory.value.text;
|
|
116
|
+
const commands = gate("closure-commands", ["switch-inputs"], () => {
|
|
117
|
+
const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle, ...switchInputs.cargoCommands].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
|
|
118
|
+
return closureCommands;
|
|
119
|
+
});
|
|
120
|
+
const closureCommands = commands.value;
|
|
121
|
+
gate("release-dependencies", ["closure-commands"], () => {
|
|
122
|
+
assertReleaseDependencies({ root, config, target, commands: closureCommands });
|
|
123
|
+
});
|
|
124
|
+
let treeText;
|
|
125
|
+
gate("feature-closure", ["closure-commands"], () => {
|
|
126
|
+
try {
|
|
127
|
+
const closure = resolveFeatureClosure({
|
|
128
|
+
root,
|
|
129
|
+
manifest: config.releaseSwitches?.manifest,
|
|
130
|
+
cargoManifest: switchInputs.cargoManifest,
|
|
131
|
+
rust: config.releaseSwitches?.rust,
|
|
132
|
+
features: config.releaseSwitches?.features ?? [],
|
|
133
|
+
// The shipped triples (never "all": the build broker refuses it); evaluated on the raw config target.
|
|
134
|
+
triples: () => shippedRustTriples({ platform, target: config.targets?.[platform] }),
|
|
135
|
+
commandTexts: closureCommands,
|
|
136
|
+
});
|
|
137
|
+
if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
|
|
138
|
+
else treeText = closure.treeText;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (strict) throw error;
|
|
141
|
+
console.log(`right-release: dry run: ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
// The scan roots: iOS scans its Xcode project and local packages; other platforms scan the app root minus the declared non-shipped dirs.
|
|
145
|
+
let sourceRoots;
|
|
146
|
+
let skipRoots;
|
|
147
|
+
let repoTop;
|
|
148
|
+
let appRootReal;
|
|
149
|
+
const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
|
|
150
|
+
if (releaseSwitchInventoryText != null) {
|
|
151
|
+
gate("source-switch-roots", ["closure-commands", ...(strict ? ["feature-closure"] : [])], () => {
|
|
152
|
+
if (platform === "ios") {
|
|
153
|
+
repoTop = git(root, ["rev-parse", "--show-toplevel"]);
|
|
154
|
+
// Git reports the canonical path; a caller's root may be a symlink or a Windows short name (hosted temp roots).
|
|
155
|
+
appRootReal = nativeReal(root);
|
|
156
|
+
const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: appRootReal, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
|
|
157
|
+
sourceRoots = [...new Set([...swiftSide, ...localCrateDirsFromTree(treeText, repoTop)])].sort();
|
|
158
|
+
console.log(`right-release: source switch scan (ios): ${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}`);
|
|
159
|
+
} else {
|
|
160
|
+
const declared = validateNonShipped({ root, treeText, nonShipped: config.releaseSwitches?.nonShipped, recipeCommands: closureCommands ?? [] });
|
|
161
|
+
if (declared.findings.length) throw new Error(`source switch inventory: ${declared.findings.length} invalid releaseSwitches.nonShipped entr${declared.findings.length === 1 ? "y" : "ies"}:\n${declared.findings.map((f) => `- ${f.reason}`).join("\n")}`);
|
|
162
|
+
skipRoots = declared.roots;
|
|
163
|
+
if (skipRoots.length) console.log(`right-release: source switch scan skips non-shipped: ${skipRoots.join(", ")}`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
if (platform === "ios") {
|
|
167
|
+
gate("source-switch-prepare", ["source-switch-roots"], () => {
|
|
168
|
+
for (const step of config.targets?.ios?.iosPackage?.prepare ?? []) {
|
|
169
|
+
const cwd = path.resolve(appRootReal, step?.cwd ?? ".");
|
|
170
|
+
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`);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// The scan itself reads the closure commands, the devtools allowance, the tree (strict: a failed closure is no input) and the roots.
|
|
176
|
+
const scanAfter = ["closure-commands", "devtools-allowance", "switch-inventory", ...(strict ? ["feature-closure"] : []), ...(releaseSwitchInventoryText != null ? ["source-switch-roots"] : [])];
|
|
177
|
+
gate("release-switches", scanAfter, () => {
|
|
178
|
+
const closureInput = { treeText, devtools: state.devtoolsAllowance, remoteHost: config.releaseSwitches?.remoteHost, remoteLoopback: config.releaseSwitches?.remoteLoopback, commands: closureCommands.map((c) => commandTokens(c)) };
|
|
179
|
+
if (releaseSwitchInventoryText != null) {
|
|
180
|
+
assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, sourceRoots, skipRoots, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
|
|
181
|
+
} else if (treeText != null || closureInput.commands.length) {
|
|
182
|
+
assertNoReleaseSwitches({ ...closureInput, appForbid: config.releaseSwitches?.forbid ?? [] }, "feature closure");
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
if (releaseSwitchInventoryText == null && strict && !failed.has("switch-inventory")) {
|
|
186
|
+
gate("switch-inventory-missing", [], () => {
|
|
187
|
+
throw new Error(`source switch inventory: ${path.relative(root, releaseSwitchInventoryPath) || releaseSwitchInventoryPath} is missing; a release build needs the app's switches.json (see packages/release/README.md, "switches.json")`);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return { findings, treeText, ...state };
|
|
191
|
+
}
|
package/progress-control.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export function releaseProgressLimits(env = process.env, { livenessOwnerTimeoutM
|
|
|
30
30
|
|
|
31
31
|
function runRightkitControl(args, { cwd, env, timeoutMs = 30_000 } = {}) {
|
|
32
32
|
return new Promise((resolve) => {
|
|
33
|
-
const control = spawn("
|
|
33
|
+
const control = spawn("rustright", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32" });
|
|
34
34
|
let output = "";
|
|
35
35
|
let timedOut = false;
|
|
36
36
|
const append = (chunk) => { output = `${output}${chunk}`.slice(-64 * 1024); };
|