@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/release.mjs
CHANGED
|
@@ -11,6 +11,7 @@ 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 { DECLARED_UNRELATED_POLICY, declaredUnrelatedRefusal, releaseArtifactPaths } from "./hosted-workflow-policy.mjs";
|
|
14
15
|
import { assertNativeIosPrepareHasManifest, assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
|
|
15
16
|
import { declaredCargoManifest, hasDefaultTauriManifest, resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
16
17
|
import {
|
|
@@ -49,13 +50,17 @@ import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs
|
|
|
49
50
|
import { assertReleaseDependencies } from "./release-dependencies.mjs";
|
|
50
51
|
import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
|
|
51
52
|
import { expandRecipeBuildCommands } from "./recipe-build.mjs";
|
|
52
|
-
import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure, shippedRustTriples } from "./release-switch-scan.mjs";
|
|
53
|
+
import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure, shippedRustTriples, validateNonShipped } from "./release-switch-scan.mjs";
|
|
53
54
|
import { iosOwnershipScope, preflightOwnershipScope } from "./ownership-scope.mjs";
|
|
54
55
|
import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
|
|
55
56
|
import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
|
|
56
57
|
import { runIosPackageRecipe } from "./ios-package-recipe.mjs";
|
|
57
58
|
import { releaseSwitchInputs } from "./ios-build-context.mjs";
|
|
58
59
|
import { productionBuildEnv, productionModeProblems } from "./production-mode.mjs";
|
|
60
|
+
import { evaluatePrebuildGates } from "./prebuild-gates.mjs";
|
|
61
|
+
import { releaseConfigFieldProblems } from "./release-config.mjs";
|
|
62
|
+
import { describeRegistryError, formatNotRun, runAdmission } from "./admission.mjs";
|
|
63
|
+
import { sccacheRefusal } from "./admission-host.mjs";
|
|
59
64
|
|
|
60
65
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
61
66
|
const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
|
|
@@ -128,11 +133,64 @@ const opts = {
|
|
|
128
133
|
dryRun: false,
|
|
129
134
|
skipChecks: false,
|
|
130
135
|
doctor: false,
|
|
136
|
+
gates: false,
|
|
137
|
+
inspect: false,
|
|
138
|
+
strict: false,
|
|
131
139
|
tier: null,
|
|
132
140
|
resume: false,
|
|
133
141
|
resumeFrom: null,
|
|
134
142
|
};
|
|
135
143
|
|
|
144
|
+
// E01: every independent problem of one run is collected and reported together; the doctor and the build share it.
|
|
145
|
+
// `check` / `reject` record a finding and continue; a check whose input comes from a failed one is not run on that bad input
|
|
146
|
+
// but reported as `not run because <check> failed` (one line, not a finding: the failed check is the one blocking problem of that
|
|
147
|
+
// cause); a throw inside a check is a finding, never a crash. `fail` stays for what cannot continue (it prints what was
|
|
148
|
+
// collected first). The build stops at one checkpoint (before the release lock, install, checks and compile) when any finding
|
|
149
|
+
// exists, so no build step or other side effect ever follows a failed check.
|
|
150
|
+
const problems = []; // { name, detail }
|
|
151
|
+
const notRunLines = []; // what a failed check kept from running; reported beside the findings, never counted as one
|
|
152
|
+
const failedChecks = new Set();
|
|
153
|
+
// The three rows only the doctor owns (admission.mjs skips them in its own pass) are not admission findings, so nothing else puts
|
|
154
|
+
// their id before the text. The doctor does, as it does for every admission row: `right-release: <row>: <text>`, the text the
|
|
155
|
+
// build's controller prints after `right-release build: <row>: `.
|
|
156
|
+
const DOCTOR_OWNED_ROWS = new Set(["rightkit-registry", "build-inputs", "dirty-inputs"]);
|
|
157
|
+
function reject(name, detail) {
|
|
158
|
+
failedChecks.add(name);
|
|
159
|
+
problems.push({ name, detail: opts.doctor && DOCTOR_OWNED_ROWS.has(name) ? `${name}: ${detail}` : detail });
|
|
160
|
+
}
|
|
161
|
+
// A validator that runs inside a check() refuses with this instead of calling fail(): the check records it as a finding and the
|
|
162
|
+
// doctor goes on to the next rule. `rowId` names the rule; `problems` lists every finding the rule has when it has more than one.
|
|
163
|
+
class ReleaseRefusal extends Error {
|
|
164
|
+
constructor(message, rowId, found = [message]) {
|
|
165
|
+
super(message);
|
|
166
|
+
this.name = "ReleaseRefusal";
|
|
167
|
+
this.rowId = rowId;
|
|
168
|
+
this.problems = found;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function rejectBlocked(name, blockers, what = name) {
|
|
172
|
+
const blocker = blockers.find((candidate) => failedChecks.has(candidate));
|
|
173
|
+
if (!blocker) return false;
|
|
174
|
+
// The check counts as failed for the checks that read it (they are not run either), but it adds no problem of its own.
|
|
175
|
+
failedChecks.add(name);
|
|
176
|
+
notRunLines.push(`${what} not run because ${blocker} failed`);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
async function check(name, after, run) {
|
|
180
|
+
if (rejectBlocked(name, after)) return { ok: false };
|
|
181
|
+
try {
|
|
182
|
+
return { ok: true, value: await run() };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof ReleaseRefusal) for (const detail of error.problems) reject(name, detail);
|
|
185
|
+
else reject(name, error instanceof Error ? error.message : `${name} threw a non-Error value: ${String(error)}`);
|
|
186
|
+
return { ok: false };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const printProblems = () => {
|
|
190
|
+
for (const problem of problems.splice(0)) console.error(`right-release: ${problem.detail}`);
|
|
191
|
+
for (const line of notRunLines.splice(0)) console.error(`right-release: ${line}`);
|
|
192
|
+
};
|
|
193
|
+
|
|
136
194
|
for (let i = 0; i < args.length; i++) {
|
|
137
195
|
const arg = args[i];
|
|
138
196
|
if (arg === "--") continue;
|
|
@@ -145,7 +203,11 @@ for (let i = 0; i < args.length; i++) {
|
|
|
145
203
|
else if (arg === "--skip-checks") opts.skipChecks = true;
|
|
146
204
|
else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
|
|
147
205
|
else if (arg === "--doctor") opts.doctor = true;
|
|
206
|
+
else if (arg === "--gates") opts.gates = true;
|
|
207
|
+
// The doctor applies the build's admission by default; --admission is the old spelling of that, kept so scripts still run.
|
|
148
208
|
else if (arg === "--admission") opts.admission = true;
|
|
209
|
+
else if (arg === "--inspect") opts.inspect = true;
|
|
210
|
+
else if (arg === "--strict") opts.strict = true;
|
|
149
211
|
else if (arg === "--resume") opts.resume = true;
|
|
150
212
|
else if (arg === "--resume-from") { opts.resume = true; opts.resumeFrom = args[++i]; }
|
|
151
213
|
else if (arg === "--tier") opts.tier = args[++i];
|
|
@@ -158,6 +220,9 @@ for (let i = 0; i < args.length; i++) {
|
|
|
158
220
|
else usage(2, `unknown argument: ${arg}`);
|
|
159
221
|
}
|
|
160
222
|
|
|
223
|
+
if (opts.gates && !opts.doctor) usage(2, "--gates is valid only with --doctor");
|
|
224
|
+
if ((opts.inspect || opts.strict) && !opts.doctor) usage(2, `${opts.inspect ? "--inspect" : "--strict"} is valid only with --doctor`);
|
|
225
|
+
if (opts.inspect && opts.admission) usage(2, "--inspect and --admission contradict each other: --inspect lists newer RightKit versions as information, --admission refuses them");
|
|
161
226
|
if (opts.upload) fail("combined build+upload was removed; run right-release build, then right-release upload --release <id> --tier patch|update");
|
|
162
227
|
if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
|
|
163
228
|
const MAC_RESUME_PHASES = ["prepackage", "sign", "package", "post-sign", "harden"];
|
|
@@ -179,9 +244,12 @@ if (opts.doctor) {
|
|
|
179
244
|
if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
|
|
180
245
|
doctorInvocation = { repoRoot, commit };
|
|
181
246
|
}
|
|
182
|
-
|
|
247
|
+
let config;
|
|
248
|
+
try { config = (await import(pathToFileURL(configPath))).default; } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
|
|
183
249
|
if (!config) usage(2, `config did not export default: ${configPath}`);
|
|
184
250
|
if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<missing>"} (expected 1)`);
|
|
251
|
+
// Field-level config refusals (updater.keyFile: keys come from the OS credential provider) are listed with every other finding.
|
|
252
|
+
for (const refusal of releaseConfigFieldProblems(config)) reject(refusal.name, refusal.detail);
|
|
185
253
|
|
|
186
254
|
// Hosted-workflow policy patterns: defined before the top-level await below uses them.
|
|
187
255
|
const SIGN_EXISTING_SECRETS = new Set(["APPLE_CERTIFICATE_BASE64", "APPLE_CERTIFICATE_PASSWORD", "APPLE_KEYCHAIN_PASSWORD"]);
|
|
@@ -205,7 +273,7 @@ const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
|
|
|
205
273
|
// (build-release.mjs). This worker, a resume and the upload consume only that admitted snapshot, through the release's own
|
|
206
274
|
// state root and admission record; a snapshot from anywhere else is refused. The standalone doctor resolves live.
|
|
207
275
|
const rightKitSnapshotFile = process.env[SNAPSHOT_ENV] || null;
|
|
208
|
-
let rightKitResolution;
|
|
276
|
+
let rightKitResolution = null; // null when the registry resolution failed (recorded as the `rightkit-registry` finding)
|
|
209
277
|
try {
|
|
210
278
|
const lockInventory = () => {
|
|
211
279
|
try {
|
|
@@ -233,14 +301,15 @@ try {
|
|
|
233
301
|
ownership: RIGHTKIT_OWNERSHIP,
|
|
234
302
|
releaseVersion: VERSION,
|
|
235
303
|
boundSnapshot,
|
|
236
|
-
// The doctor
|
|
237
|
-
|
|
304
|
+
// The doctor is the build's own admission: a pin behind latest is refused, as the build refuses it. --inspect is the
|
|
305
|
+
// informational view (newer versions are listed, not refused).
|
|
306
|
+
admission: opts.doctor && !opts.inspect,
|
|
238
307
|
});
|
|
239
308
|
} catch (error) {
|
|
240
|
-
|
|
241
|
-
|
|
309
|
+
rightKitResolution = null;
|
|
310
|
+
reject("rightkit-registry", describeRegistryError(error, config.app ?? path.basename(root)));
|
|
242
311
|
}
|
|
243
|
-
{
|
|
312
|
+
if (rightKitResolution) {
|
|
244
313
|
const { newer, tasks, warnings, notes } = rightKitResolution.freshness;
|
|
245
314
|
const out = opts.doctor ? console.log : console.error;
|
|
246
315
|
for (const line of newer) out(`right-release: newer RightKit version (informational): ${line}`);
|
|
@@ -248,88 +317,133 @@ try {
|
|
|
248
317
|
for (const line of warnings) console.error(`right-release: WARNING ${line}`);
|
|
249
318
|
for (const line of notes) console.error(`right-release: note ${line}`);
|
|
250
319
|
}
|
|
251
|
-
rightKitVerifiedNpm = rightKitResolution.verified.npm;
|
|
252
|
-
//
|
|
320
|
+
if (rightKitResolution) rightKitVerifiedNpm = rightKitResolution.verified.npm;
|
|
321
|
+
// The contracts below are independent of each other; the two that read the proven RightKit versions need the registry resolution.
|
|
322
|
+
// A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate. Each package rule is its
|
|
323
|
+
// own finding (a failed one blocks only the rules that read its result), so one run lists every violation.
|
|
253
324
|
if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
|
|
254
|
-
await
|
|
255
|
-
|
|
256
|
-
|
|
325
|
+
const packageJson = await check("package-json", [], async () => JSON.parse(await readFile(path.join(root, "package.json"), "utf8")));
|
|
326
|
+
const packageLabel = () => config.app ?? packageJson.value?.name ?? "app";
|
|
327
|
+
await check("qa-backdoor-contract", ["package-json"], () => assertQaBackdoorContract(root, packageJson.value.scripts ?? {}));
|
|
328
|
+
await check("hosted-workflows", [], () => validateHostedWorkflowFiles(root, packageLabel(), config.hostedWorkflows, releaseArtifactPaths(config)));
|
|
329
|
+
await check("package-manager", ["package-json"], () => validatePackageManagerPin(packageJson.value, packageLabel()));
|
|
330
|
+
await check("package-workspace-scripts", ["package-json"], () => validateNoParentWorkspaceScripts(packageJson.value, packageLabel()));
|
|
331
|
+
await check("rightkit-pin-specs", ["package-json", "rightkit-registry"], () => validateRightKitPinSpecs(packageJson.value, packageLabel()));
|
|
332
|
+
await check("rightkit-release-supported", ["package-json", "rightkit-registry"], () => validateRightKitReleaseSupported(packageJson.value, packageLabel()));
|
|
333
|
+
}
|
|
334
|
+
await check("rightkit-cargo-contract", ["rightkit-registry"], () => {
|
|
335
|
+
validateRightKitCargoContract(root, mergeVerifiedVersions(RIGHTKIT_CARGO_ALLOWED, rightKitResolution.verified.cargo), config.app ?? path.basename(root));
|
|
336
|
+
});
|
|
257
337
|
// One standard dev profile and panic = "unwind" in every app (templates/cargo-profiles.toml).
|
|
258
|
-
assertCargoProfileContract(root, config.app ?? path.basename(root));
|
|
338
|
+
await check("cargo-profile-contract", [], () => assertCargoProfileContract(root, config.app ?? path.basename(root)));
|
|
259
339
|
// RightKit QA is mandatory for every app (also SwiftPM apps without a package.json): no QA lane refuses the release.
|
|
260
|
-
assertQaAdoption({ appRoot: root, app: config.app });
|
|
340
|
+
await check("qa-adoption", [], () => assertQaAdoption({ appRoot: root, app: config.app }));
|
|
261
341
|
let target = config.targets?.[opts.platform];
|
|
262
342
|
// Declarative winPackage / macPackage (#345) expand to the same hook shape every gate below already checks.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
343
|
+
// When it fails, `target` is not usable: every check that reads it is reported as not run because `target` failed.
|
|
344
|
+
await check("target", [], () => {
|
|
345
|
+
let expanded;
|
|
346
|
+
try { expanded = expandPackageRecipeTarget(target, { platform: opts.platform, appRoot: root, config }); } catch (error) { throw new Error(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
|
|
347
|
+
if (!expanded) throw new Error(`${config.app ?? "app"} has no ${opts.platform} release target`);
|
|
348
|
+
target = expanded;
|
|
349
|
+
});
|
|
350
|
+
const legalCheck = await check("legal-contract", [], () => (config.legal
|
|
266
351
|
? assertLegalReleaseContract(root, config.legal, config.app ?? path.basename(root), opts.platform)
|
|
267
|
-
: null;
|
|
268
|
-
|
|
269
|
-
fail(`${config.app ?? "app"} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
|
|
270
|
-
}
|
|
352
|
+
: null));
|
|
353
|
+
const legalContract = legalCheck.ok ? legalCheck.value : null;
|
|
271
354
|
let macSigning; // validated macOS sign.prePackageFiles / sign.nestedCode (mac only)
|
|
272
355
|
let mediaSidecarConfig = null; // validated targets.<mac|win>.mediaSidecar (rightkit-ffmpeg decode sidecar)
|
|
273
|
-
if (target.mediaSidecar !== undefined) {
|
|
274
|
-
if (opts.platform !== "mac" && opts.platform !== "win") fail(`${config.app ?? "app"} ${opts.platform} does not support mediaSidecar (mac and win only)`);
|
|
275
|
-
try { mediaSidecarConfig = validateMediaSidecarConfig(target.mediaSidecar, { platform: opts.platform }); } catch (error) { fail(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
|
|
276
|
-
}
|
|
277
356
|
let externalBinConfig = null; // validated targets.<mac|win>.externalBin (generic Tauri externalBin sidecars, #270)
|
|
278
|
-
if (target.externalBin !== undefined) {
|
|
279
|
-
if (opts.platform !== "mac" && opts.platform !== "win") fail(`${config.app ?? "app"} ${opts.platform} does not support externalBin (mac and win only)`);
|
|
280
|
-
try { externalBinConfig = validateExternalBinConfig(target.externalBin, { platform: opts.platform, reservedNames: mediaSidecarConfig?.tools ?? [] }); } catch (error) { fail(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
|
|
281
|
-
}
|
|
282
|
-
if (opts.platform === "win") {
|
|
283
|
-
const isNsis = target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT;
|
|
284
|
-
const isPortable = target.signingContract === WINDOWS_PORTABLE_SIGNING_CONTRACT;
|
|
285
|
-
const isMsix = target.signingContract === MSIX_SIGNING_CONTRACT;
|
|
286
|
-
if (!isNsis && !isPortable && !isMsix) fail(`${config.app ?? "app"} win must declare signingContract: ${WINDOWS_NSIS_SIGNING_CONTRACT}, ${WINDOWS_PORTABLE_SIGNING_CONTRACT} or ${MSIX_SIGNING_CONTRACT}`);
|
|
287
|
-
if (isMsix) {
|
|
288
|
-
try { assertValidMsixTarget(target, config.app ?? "app"); } catch (error) { fail(error.message); }
|
|
289
|
-
} else {
|
|
290
|
-
if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} win must declare prePackage for the raw EXE build`);
|
|
291
|
-
if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} win must declare at least one sign.prePackageFiles raw EXE`);
|
|
292
|
-
if (target.sign.prePackageFiles.some((file) => typeof file !== "string") || target.sign.nestedCode !== undefined) fail(`${config.app ?? "app"} win sign.prePackageFiles entries must be path strings; object entries and sign.nestedCode are macOS-only`);
|
|
293
|
-
if (isNsis && target.sign.prePackageFiles.length !== 1) fail(`${config.app ?? "app"} win NSIS must declare exactly one sign.prePackageFiles raw EXE`);
|
|
294
|
-
if (isNsis && !target.sign?.files?.length) fail(`${config.app ?? "app"} win NSIS must declare sign.files for installer signing`);
|
|
295
|
-
if (isNsis && target.sign.prePackageFiles.some((file) => target.sign.files.includes(file))) fail(`${config.app ?? "app"} win raw EXE and installer signing files must be distinct`);
|
|
296
|
-
if (isPortable && target.sign?.files?.length) fail(`${config.app ?? "app"} win portable release must sign every executable through sign.prePackageFiles and omit sign.files`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
if (opts.platform === "mac") {
|
|
300
|
-
if (target.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT) fail(`${config.app ?? "app"} mac must declare signingContract: ${MACOS_PORTABLE_SIGNING_CONTRACT}`);
|
|
301
|
-
if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} mac must declare prePackage to materialize Developer ID signing files`);
|
|
302
|
-
if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} mac must declare at least one sign.prePackageFiles`);
|
|
303
|
-
const macSignConfig = mediaSidecarConfig || externalBinConfig
|
|
304
|
-
? { ...target.sign, nestedCode: { ...(mediaSidecarConfig ? mediaSidecarNestedCode(mediaSidecarConfig) : {}), ...(externalBinConfig ? externalBinNestedCode(externalBinConfig) : {}), ...(target.sign.nestedCode ?? {}) } }
|
|
305
|
-
: target.sign;
|
|
306
|
-
try { macSigning = validateMacSigningConfig(macSignConfig, { root }); } catch (error) { fail(`${config.app ?? "app"} mac ${error.message}`); }
|
|
307
|
-
if (!target.notarize?.file) fail(`${config.app ?? "app"} mac must declare notarize.file`);
|
|
308
|
-
if (target.notarize.appBeforePackage && !target.notarize.app) fail(`${config.app ?? "app"} mac notarize.appBeforePackage requires notarize.app`);
|
|
309
|
-
if (target.notarize.staple === false && (target.notarize.app || /\.(dmg|pkg)$/i.test(target.notarize.file))) {
|
|
310
|
-
fail(`${config.app ?? "app"} mac notarize.staple: false applies only to an archive of bare executables (no notarize.app, not a .dmg/.pkg)`);
|
|
311
|
-
}
|
|
312
|
-
const swiftDmgError = swiftMacDmgConfigError({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: config.app ?? "app" });
|
|
313
|
-
if (swiftDmgError) fail(swiftDmgError);
|
|
314
|
-
if (target.dmg && typeof target.dmg === "object" && target.dmg.spec !== undefined) {
|
|
315
|
-
try {
|
|
316
|
-
if (typeof target.dmg.spec !== "string" || !target.dmg.spec) throw new Error("dmg.spec must be a path string");
|
|
317
|
-
const specOptions = readDmgSpec(path.resolve(root, target.dmg.spec), { app: target.notarize.app });
|
|
318
|
-
for (const asset of [specOptions.background, specOptions.volumeIcon]) if (asset && !existsSync(asset)) throw new Error(`referenced file not found: ${asset}`);
|
|
319
|
-
} catch (error) { fail(`${config.app ?? "app"} mac targets.mac.dmg.spec: ${error.message}`); }
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
357
|
let androidTarget; // validated Android AAB target (android only; see android-release.mjs)
|
|
323
|
-
if (opts.platform === "android") {
|
|
324
|
-
try { androidTarget = validateAndroidTarget(target, { root }); } catch (error) { fail(`${config.app ?? "app"} android ${error.message}`); }
|
|
325
|
-
}
|
|
326
358
|
let iosSpec; // validated iOS iosPackage spec (ios only; package-recipe-targets.mjs expands targets.ios, ios-package-recipe.mjs runs it)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
359
|
+
const app = config.app ?? "app";
|
|
360
|
+
// Everything below reads the expanded target. Each independent refusal is its own finding; a check that needs the output of
|
|
361
|
+
// another (mac signing reads the sidecar config, the iOS prepare check reads the iOS spec) is not run (reported as such), never run on it.
|
|
362
|
+
if (!rejectBlocked("target-checks", ["target"], "the target checks (signed, mediaSidecar, externalBin, publishBlocked and the platform signing and package contract)")) {
|
|
363
|
+
if (target.signed !== true) reject("target-signed", `${app} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
|
|
364
|
+
if (target.mediaSidecar !== undefined) {
|
|
365
|
+
if (opts.platform !== "mac" && opts.platform !== "win") reject("media-sidecar", `${app} ${opts.platform} does not support mediaSidecar (mac and win only)`);
|
|
366
|
+
else await check("media-sidecar", [], () => {
|
|
367
|
+
try { mediaSidecarConfig = validateMediaSidecarConfig(target.mediaSidecar, { platform: opts.platform }); } catch (error) { throw new Error(`${app} ${opts.platform} ${error.message}`); }
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
if (target.externalBin !== undefined) {
|
|
371
|
+
if (opts.platform !== "mac" && opts.platform !== "win") reject("external-bin", `${app} ${opts.platform} does not support externalBin (mac and win only)`);
|
|
372
|
+
else await check("external-bin", ["media-sidecar"], () => {
|
|
373
|
+
try { externalBinConfig = validateExternalBinConfig(target.externalBin, { platform: opts.platform, reservedNames: mediaSidecarConfig?.tools ?? [] }); } catch (error) { throw new Error(`${app} ${opts.platform} ${error.message}`); }
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (opts.platform === "win") {
|
|
377
|
+
const isNsis = target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT;
|
|
378
|
+
const isPortable = target.signingContract === WINDOWS_PORTABLE_SIGNING_CONTRACT;
|
|
379
|
+
const isMsix = target.signingContract === MSIX_SIGNING_CONTRACT;
|
|
380
|
+
if (!isNsis && !isPortable && !isMsix) reject("win-signing-contract", `${app} win must declare signingContract: ${WINDOWS_NSIS_SIGNING_CONTRACT}, ${WINDOWS_PORTABLE_SIGNING_CONTRACT} or ${MSIX_SIGNING_CONTRACT}`);
|
|
381
|
+
if (isMsix) {
|
|
382
|
+
await check("win-msix", [], () => assertValidMsixTarget(target, app));
|
|
383
|
+
} else {
|
|
384
|
+
// The NSIS and portable rules below depend on which contract the target declares.
|
|
385
|
+
rejectBlocked("win-contract-rules", ["win-signing-contract"], "the win NSIS and portable rules (sign.files, distinct raw EXE and installer files)");
|
|
386
|
+
if (!target.prePackage?.cmd) reject("win-prepackage", `${app} win must declare prePackage for the raw EXE build`);
|
|
387
|
+
const rawFiles = target.sign?.prePackageFiles;
|
|
388
|
+
if (!rawFiles?.length) reject("win-sign-prepackage-files", `${app} win must declare at least one sign.prePackageFiles raw EXE`);
|
|
389
|
+
else {
|
|
390
|
+
if (rawFiles.some((file) => typeof file !== "string") || target.sign.nestedCode !== undefined) reject("win-sign-entries", `${app} win sign.prePackageFiles entries must be path strings; object entries and sign.nestedCode are macOS-only`);
|
|
391
|
+
if (isNsis && rawFiles.length !== 1) reject("win-nsis-raw-exe", `${app} win NSIS must declare exactly one sign.prePackageFiles raw EXE`);
|
|
392
|
+
}
|
|
393
|
+
if (isNsis && !target.sign?.files?.length) reject("win-nsis-sign-files", `${app} win NSIS must declare sign.files for installer signing`);
|
|
394
|
+
if (isNsis && rawFiles?.length && target.sign?.files?.length && rawFiles.some((file) => target.sign.files.includes(file))) reject("win-distinct-files", `${app} win raw EXE and installer signing files must be distinct`);
|
|
395
|
+
if (isPortable && target.sign?.files?.length) reject("win-portable-sign-files", `${app} win portable release must sign every executable through sign.prePackageFiles and omit sign.files`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (opts.platform === "mac") {
|
|
399
|
+
if (target.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT) reject("mac-signing-contract", `${app} mac must declare signingContract: ${MACOS_PORTABLE_SIGNING_CONTRACT}`);
|
|
400
|
+
if (!target.prePackage?.cmd) reject("mac-prepackage", `${app} mac must declare prePackage to materialize Developer ID signing files`);
|
|
401
|
+
if (!target.sign?.prePackageFiles?.length) reject("mac-sign-prepackage-files", `${app} mac must declare at least one sign.prePackageFiles`);
|
|
402
|
+
else if (!rejectBlocked("mac-signing", ["media-sidecar", "external-bin"], `${app} mac signing config validation (it reads the sidecar and externalBin configs)`)) {
|
|
403
|
+
await check("mac-signing", [], () => {
|
|
404
|
+
const macSignConfig = mediaSidecarConfig || externalBinConfig
|
|
405
|
+
? { ...target.sign, nestedCode: { ...(mediaSidecarConfig ? mediaSidecarNestedCode(mediaSidecarConfig) : {}), ...(externalBinConfig ? externalBinNestedCode(externalBinConfig) : {}), ...(target.sign.nestedCode ?? {}) } }
|
|
406
|
+
: target.sign;
|
|
407
|
+
try { macSigning = validateMacSigningConfig(macSignConfig, { root }); } catch (error) { throw new Error(`${app} mac ${error.message}`); }
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (!target.notarize?.file) reject("mac-notarize-file", `${app} mac must declare notarize.file`);
|
|
411
|
+
else {
|
|
412
|
+
if (target.notarize.appBeforePackage && !target.notarize.app) reject("mac-notarize-app", `${app} mac notarize.appBeforePackage requires notarize.app`);
|
|
413
|
+
if (target.notarize.staple === false && (target.notarize.app || /\.(dmg|pkg)$/i.test(target.notarize.file))) {
|
|
414
|
+
reject("mac-notarize-staple", `${app} mac notarize.staple: false applies only to an archive of bare executables (no notarize.app, not a .dmg/.pkg)`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
await check("mac-swift-dmg", [], () => {
|
|
418
|
+
const swiftDmgError = swiftMacDmgConfigError({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: app });
|
|
419
|
+
if (swiftDmgError) throw new Error(swiftDmgError);
|
|
420
|
+
});
|
|
421
|
+
if (target.dmg && typeof target.dmg === "object" && target.dmg.spec !== undefined && !rejectBlocked("mac-dmg-spec", ["mac-notarize-file"], `${app} mac targets.mac.dmg.spec validation (it reads notarize.app)`)) {
|
|
422
|
+
await check("mac-dmg-spec", [], () => {
|
|
423
|
+
try {
|
|
424
|
+
if (typeof target.dmg.spec !== "string" || !target.dmg.spec) throw new Error("dmg.spec must be a path string");
|
|
425
|
+
const specOptions = readDmgSpec(path.resolve(root, target.dmg.spec), { app: target.notarize.app });
|
|
426
|
+
for (const asset of [specOptions.background, specOptions.volumeIcon]) if (asset && !existsSync(asset)) throw new Error(`referenced file not found: ${asset}`);
|
|
427
|
+
} catch (error) { throw new Error(`${app} mac targets.mac.dmg.spec: ${error.message}`); }
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (opts.platform === "android") {
|
|
432
|
+
await check("android-target", [], () => {
|
|
433
|
+
try { androidTarget = validateAndroidTarget(target, { root }); } catch (error) { throw new Error(`${app} android ${error.message}`); }
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (opts.platform === "ios") {
|
|
437
|
+
if (target.recipe?.kind !== "iosPackage") reject("ios-target", `${app} ios must declare targets.ios.iosPackage; there is no hand-written iOS package script`);
|
|
438
|
+
else {
|
|
439
|
+
iosSpec = target.recipe.spec;
|
|
440
|
+
if (nativeIos) await check("ios-prepare-manifest", [], () => {
|
|
441
|
+
try { assertNativeIosPrepareHasManifest(iosSpec, config, root); } catch (error) { throw new Error(`${app} ios ${error.message}`); }
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (opts.upload && target.publishBlocked) reject("publish-blocked", `${app} ${opts.platform} publish blocked: ${target.publishBlocked}`);
|
|
331
446
|
}
|
|
332
|
-
if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
|
|
333
447
|
/**
|
|
334
448
|
* Assemble preflight inputs from the app's own files. Kept here (not in
|
|
335
449
|
* preflight.mjs) so the check module stays free of release.mjs's config shape.
|
|
@@ -377,16 +491,28 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
|
|
|
377
491
|
}
|
|
378
492
|
|
|
379
493
|
if (opts.doctor) {
|
|
380
|
-
|
|
494
|
+
// Every check below is independent unless it names what it needs; a failed input blocks (never feeds) what reads it.
|
|
495
|
+
const inputs = await check("build-inputs", ["target"], () => resolveReleaseBuildInputs({
|
|
381
496
|
repoRoot: doctorInvocation.repoRoot,
|
|
382
497
|
appRoot: root,
|
|
383
498
|
configPath,
|
|
384
499
|
config,
|
|
385
500
|
target,
|
|
501
|
+
}));
|
|
502
|
+
await check("dirty-inputs", ["build-inputs"], () => {
|
|
503
|
+
const dirtyInputs = dirtyBuildInputs(doctorInvocation.repoRoot, inputs.value.buildInputs, doctorInvocation.commit);
|
|
504
|
+
if (dirtyInputs.length > 0) throw new Error(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
|
|
386
505
|
});
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
506
|
+
// The build's own admission rows (admission.mjs): the files the build would read and this machine, run with the texts and the
|
|
507
|
+
// rows the build runs before it takes its lock. The registry, the build inputs and the dirty inputs are this doctor's own steps.
|
|
508
|
+
let notRun = [];
|
|
509
|
+
const admission = await check("admission", ["target"], () => runAdmission({
|
|
510
|
+
root, repoRoot: doctorInvocation.repoRoot, configPath, config, platform: opts.platform, target, tiers: ["files", "host"],
|
|
511
|
+
mode: "doctor", skipDoctorOwned: true, skipChecks: opts.skipChecks, commit: doctorInvocation.commit,
|
|
512
|
+
}));
|
|
513
|
+
if (admission.ok) {
|
|
514
|
+
for (const finding of admission.value.findings) reject(finding.row, `${finding.row}: ${finding.detail}`);
|
|
515
|
+
notRun = admission.value.notRun;
|
|
390
516
|
}
|
|
391
517
|
console.log(`right-release ${VERSION}`);
|
|
392
518
|
console.log(`config: ${configPath}`);
|
|
@@ -394,7 +520,7 @@ if (opts.doctor) {
|
|
|
394
520
|
console.log(`platform: ${opts.platform}`);
|
|
395
521
|
console.log(`tier: ${opts.tier ?? "<required for release/publish>"}`);
|
|
396
522
|
console.log(`packageManager: ${config.packageManager}`);
|
|
397
|
-
console.log(`rightkit-registry: ${rightKitResolution.summary}`);
|
|
523
|
+
if (rightKitResolution) console.log(`rightkit-registry: ${rightKitResolution.summary}`);
|
|
398
524
|
console.log(`workdir: ${workdir}`);
|
|
399
525
|
console.log(`hardeningscan: ${HARDENING_SCAN}`);
|
|
400
526
|
if (sccacheVersion) console.log(`sccache: ${sccacheVersion}`);
|
|
@@ -403,125 +529,81 @@ if (opts.doctor) {
|
|
|
403
529
|
console.log(`legalAcceptance: ${legalContract.acceptanceVersion}`);
|
|
404
530
|
console.log(`legalManifestSha256: ${legalContract.manifestSha256}`);
|
|
405
531
|
}
|
|
406
|
-
if (
|
|
407
|
-
|
|
532
|
+
if (!failedChecks.has("target")) {
|
|
533
|
+
if (target.sign?.files?.length) console.log(`sign: ${target.sign.files.join(", ")}`);
|
|
534
|
+
if (target.publishBlocked) console.log(`publishBlocked: ${target.publishBlocked}`);
|
|
535
|
+
}
|
|
408
536
|
const managedRoot = process.env.RIGHTKIT_MANAGED_ROOT;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
537
|
+
await check("artifact-storage", [], () => {
|
|
538
|
+
const installerCopyFindings = findInstallerCopyScripts({
|
|
539
|
+
appRoot: root,
|
|
540
|
+
workspaceRoot: process.env.RIGHTKIT_WORKSPACE_ROOT ?? path.resolve(doctorInvocation.repoRoot, ".."),
|
|
541
|
+
});
|
|
542
|
+
const storageFindings = [
|
|
543
|
+
...installerCopyFindings,
|
|
544
|
+
...findLargeRepositorySubdirectories({ appRoot: root, managedRoot, declared: config.storage?.declaredStores ?? [], thresholdBytes: Number(process.env.RIGHTKIT_ARTIFACT_STORAGE_THRESHOLD_BYTES ?? DEFAULT_STORAGE_THRESHOLD_BYTES) }),
|
|
545
|
+
];
|
|
546
|
+
if (storageFindings.length) {
|
|
547
|
+
console.log("artifact-storage: report-only storage findings detected:");
|
|
548
|
+
for (const finding of storageFindings) console.log(` - ${finding.path}: ${finding.reason}`);
|
|
549
|
+
throw new Error(`artifact-storage found ${storageFindings.length} repository storage finding(s); move outputs to RIGHT_RELEASE_ARTIFACT_DIR`);
|
|
550
|
+
} else console.log("artifact-storage: no installer-copy scripts detected");
|
|
412
551
|
});
|
|
413
|
-
const storageFindings = [
|
|
414
|
-
...installerCopyFindings,
|
|
415
|
-
...findLargeRepositorySubdirectories({ appRoot: root, managedRoot, declared: config.storage?.declaredStores ?? [], thresholdBytes: Number(process.env.RIGHTKIT_ARTIFACT_STORAGE_THRESHOLD_BYTES ?? DEFAULT_STORAGE_THRESHOLD_BYTES) }),
|
|
416
|
-
];
|
|
417
|
-
if (storageFindings.length) {
|
|
418
|
-
console.log("artifact-storage: report-only storage findings detected:");
|
|
419
|
-
for (const finding of storageFindings) console.log(` - ${finding.path}: ${finding.reason}`);
|
|
420
|
-
fail(`artifact-storage found ${storageFindings.length} repository storage finding(s); move outputs to RIGHT_RELEASE_ARTIFACT_DIR`);
|
|
421
|
-
} else console.log("artifact-storage: no installer-copy scripts detected");
|
|
422
552
|
|
|
423
553
|
// Config above, MACHINE below. Printing config never told anyone whether this
|
|
424
|
-
// box could finish a build; these checks do.
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
554
|
+
// box could finish a build; these checks do. The machine preflight reads the resolved build inputs, so it is
|
|
555
|
+
// BLOCKED when those could not be resolved.
|
|
556
|
+
let failures = [];
|
|
557
|
+
const machine = await check("preflight", ["target", "build-inputs"], () => {
|
|
558
|
+
const { nativeLayout: resolvedNativeLayout, cargoLockPaths: pinnedCargoLocks } = inputs.value;
|
|
559
|
+
return releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout, pinnedCargoLocks });
|
|
560
|
+
});
|
|
561
|
+
if (machine.ok) {
|
|
562
|
+
console.log("preflight:");
|
|
563
|
+
console.log(formatPreflight(machine.value));
|
|
564
|
+
failures = preflightFailures(machine.value);
|
|
565
|
+
}
|
|
566
|
+
// --gates: run the build's pre-build source gates (release-switch scan, feature closure, release dependencies,
|
|
567
|
+
// devtools, production mode) over this app and report each finding as a FAIL row. They read the expanded target
|
|
568
|
+
// (and, on iOS, the iOS spec), so a failed one of those blocks them.
|
|
569
|
+
let gateFindings = [];
|
|
570
|
+
if (opts.gates) {
|
|
571
|
+
const blocker = ["target", ...(opts.platform === "ios" ? ["ios-target"] : [])].find((name) => failedChecks.has(name));
|
|
572
|
+
gateFindings = blocker
|
|
573
|
+
? [{ name: "prebuild-gates", detail: `BLOCKED by ${blocker}` }]
|
|
574
|
+
: evaluatePrebuildGates({ root, config, target, platform: opts.platform, iosSpec, nativeIos, nativeAssembly, nativeFinalizer, strict: !opts.dryRun }).findings;
|
|
575
|
+
}
|
|
576
|
+
const configProblems = problems.length;
|
|
577
|
+
// The config findings (stderr, as every early refusal always was) and the gate rows (stdout) of this one run.
|
|
578
|
+
printProblems();
|
|
579
|
+
if (gateFindings.length) {
|
|
580
|
+
console.log(gateFindings.map((finding) => ` [FAIL] gate:${finding.name}: ${finding.detail}`).join("\n"));
|
|
581
|
+
}
|
|
582
|
+
// A row that could not run on this host is not a pass; --strict makes it a blocking problem.
|
|
583
|
+
for (const line of formatNotRun({ notRun })) console.error(`right-release: ${line}`);
|
|
584
|
+
const total = failures.length + gateFindings.length + configProblems + (opts.strict ? notRun.length : 0);
|
|
585
|
+
if (total > 0) {
|
|
586
|
+
console.error(`right-release: preflight found ${total} blocking problem(s); the build would fail on these`);
|
|
587
|
+
process.exit(1);
|
|
431
588
|
}
|
|
432
589
|
process.exit(0);
|
|
433
590
|
}
|
|
434
591
|
|
|
435
592
|
let prePackScanned = false; // set once a pre-pack payload scan has seen a native binary
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
{ label: "target.package", command: target.package },
|
|
451
|
-
{ label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
|
|
452
|
-
{ label: "nativeAssembly.finalizer", command: nativeFinalizer },
|
|
453
|
-
...commandList(target.postSign).map((command, index) => ({ label: Array.isArray(target.postSign) ? `target.postSign[${index}]` : "target.postSign", command })),
|
|
454
|
-
{ label: "target.gradle", command: target.gradle },
|
|
455
|
-
] });
|
|
456
|
-
} catch (error) { fail(error.message); }
|
|
457
|
-
// M12: the frontend build resolves in production mode only (development-only conditional exports such as
|
|
458
|
-
// `@rightkit/license/testing` never resolve). `run` pins NODE_ENV=production for build children; this refuses a
|
|
459
|
-
// Vite config that lists the `development` resolve condition explicitly, which the pin cannot override.
|
|
460
|
-
{
|
|
461
|
-
const modeProblems = productionModeProblems({ appRoot: root });
|
|
462
|
-
if (modeProblems.length) fail(`${config.app ?? "app"} release frontend is not production-only:\n- ${modeProblems.join("\n- ")}`);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// D4 (M21/M23) source layer: every environment/argument read in app source is registered in the app's switches.json and
|
|
466
|
-
// every S1-S4 selector is compiled out of release. The inventory is `config.releaseSwitches.inventory` (relative to the
|
|
467
|
-
// app root) or `switches.json`; a release without one fails (a dry run only checks one that exists).
|
|
468
|
-
// The resolved feature graph (`cargo tree -e features`) is the AUTHORITATIVE test-mode check: it runs here, in the app
|
|
469
|
-
// repository, for the release target, and a release that cannot run it fails (a dry run reports and continues).
|
|
470
|
-
// The binary marker/name scan below is secondary evidence.
|
|
471
|
-
const releaseSwitchInventoryPath = path.resolve(root, config.releaseSwitches?.inventory ?? "switches.json");
|
|
472
|
-
const releaseSwitchInventoryText = existsSync(releaseSwitchInventoryPath) ? readFileSync(releaseSwitchInventoryPath, "utf8") : null;
|
|
473
|
-
try {
|
|
474
|
-
// A `node recipe-build.mjs --plan <JSON>` prePackage hides each step's `--features` in one argument: the closure and the
|
|
475
|
-
// command token scan see every prepare / build / postBuild step (a plan that cannot be read or validated fails closed).
|
|
476
|
-
const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle, ...switchInputs.cargoCommands].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
|
|
477
|
-
assertReleaseDependencies({ root, config, target, commands: closureCommands });
|
|
478
|
-
let treeText;
|
|
479
|
-
try {
|
|
480
|
-
const closure = resolveFeatureClosure({
|
|
481
|
-
root,
|
|
482
|
-
manifest: config.releaseSwitches?.manifest,
|
|
483
|
-
cargoManifest: switchInputs.cargoManifest,
|
|
484
|
-
rust: config.releaseSwitches?.rust,
|
|
485
|
-
features: config.releaseSwitches?.features ?? [],
|
|
486
|
-
// The shipped triples (never "all": the build broker refuses it); evaluated on the raw config target.
|
|
487
|
-
triples: () => shippedRustTriples({ platform: opts.platform, target: config.targets?.[opts.platform] }),
|
|
488
|
-
commandTexts: closureCommands,
|
|
489
|
-
});
|
|
490
|
-
if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
|
|
491
|
-
else treeText = closure.treeText;
|
|
492
|
-
} catch (error) {
|
|
493
|
-
if (!opts.dryRun) throw error;
|
|
494
|
-
console.log(`right-release: dry run: ${error.message}`);
|
|
495
|
-
}
|
|
496
|
-
// remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
|
|
497
|
-
// remoteLoopback: the daemon `remote-loopback` feature is a separate declaration (releaseSwitches.remoteLoopback: true); remoteHost does not cover it.
|
|
498
|
-
const closureInput = { treeText, devtools: devtoolsAllowance, remoteHost: config.releaseSwitches?.remoteHost, remoteLoopback: config.releaseSwitches?.remoteLoopback, commands: closureCommands.map((c) => commandTokens(c)) };
|
|
499
|
-
if (releaseSwitchInventoryText != null) {
|
|
500
|
-
// iOS: only what the iPhone app compiles — the Xcode project and its local packages, plus the local crates in the
|
|
501
|
-
// resolved aarch64-apple-ios cargo graph (feature-aware, unlike the ownership closure). Other targets walk the app root.
|
|
502
|
-
let sourceRoots;
|
|
503
|
-
if (opts.platform === "ios") {
|
|
504
|
-
const repoTop = git(root, ["rev-parse", "--show-toplevel"]);
|
|
505
|
-
// Swift side only: the Rust side is the resolved cargo graph below. The ownership scope also folds in the whole
|
|
506
|
-
// nativeAssembly workspace, which would scan crates (desktop tools, the TUI) the iPhone never links.
|
|
507
|
-
const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: root, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
|
|
508
|
-
sourceRoots = [...new Set([...swiftSide, ...localCrateDirsFromTree(treeText, repoTop)])].sort();
|
|
509
|
-
// A prepare step can stage sources from anywhere (a web bundle, a generated resource) into the app; its working directory must be
|
|
510
|
-
// inside the scanned roots, or the release fails instead of shipping what the scan never saw. Add the directory to ownershipScope.
|
|
511
|
-
for (const step of config.targets?.ios?.iosPackage?.prepare ?? []) {
|
|
512
|
-
const cwd = path.resolve(root, step?.cwd ?? ".");
|
|
513
|
-
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`);
|
|
514
|
-
}
|
|
515
|
-
console.log(`right-release: source switch scan (ios): ${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}`);
|
|
516
|
-
}
|
|
517
|
-
assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, sourceRoots, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
|
|
518
|
-
} else if (treeText != null || closureInput.commands.length) {
|
|
519
|
-
assertNoReleaseSwitches({ ...closureInput, appForbid: config.releaseSwitches?.forbid ?? [] }, "feature closure");
|
|
520
|
-
}
|
|
521
|
-
if (releaseSwitchInventoryText == null && !opts.dryRun) {
|
|
522
|
-
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")`);
|
|
523
|
-
}
|
|
524
|
-
} catch (error) { fail(error.message); }
|
|
593
|
+
// One checkpoint for the build: every config finding of this run and every source-gate finding are reported together, and
|
|
594
|
+
// nothing below (lock, install, checks, compile, signing) runs while any exists.
|
|
595
|
+
// Source-level gates (release-switch scan, devtools, feature closure, release dependencies, production mode) live in
|
|
596
|
+
// prebuild-gates.mjs so `doctor --gates` runs the exact same code in the same order. They read the expanded target (and the
|
|
597
|
+
// iOS spec), so a failed one of those blocks them.
|
|
598
|
+
const prebuildBlocker = ["target", ...(opts.platform === "ios" ? ["ios-target"] : [])].find((name) => failedChecks.has(name));
|
|
599
|
+
const { findings: prebuildFindings, devtoolsAllowance, inventoryText: releaseSwitchInventoryText } = prebuildBlocker
|
|
600
|
+
? { findings: [{ name: "prebuild-gates", detail: `BLOCKED by ${prebuildBlocker}` }], devtoolsAllowance: { mode: "denied" }, inventoryText: null }
|
|
601
|
+
: evaluatePrebuildGates({ root, config, target, platform: opts.platform, iosSpec, nativeIos, nativeAssembly, nativeFinalizer, strict: !opts.dryRun });
|
|
602
|
+
if (problems.length || prebuildFindings.length) {
|
|
603
|
+
printProblems();
|
|
604
|
+
for (const finding of prebuildFindings) console.error(`right-release: ${finding.detail.startsWith("BLOCKED by ") ? `gate ${finding.name}: ` : ""}${finding.detail}`);
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
525
607
|
|
|
526
608
|
const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
|
|
527
609
|
const mode = "package";
|
|
@@ -700,7 +782,8 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
|
|
|
700
782
|
if (target.postSign) await runCommands(target.postSign, root);
|
|
701
783
|
const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => resolveReleaseArtifactPath(root, artifact.file)))];
|
|
702
784
|
if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
|
|
703
|
-
|
|
785
|
+
// The key comes from the OS credential provider inside sign-updater; targets.win.updater.keyFile is refused at config validation and never forwarded.
|
|
786
|
+
await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
|
|
704
787
|
}
|
|
705
788
|
|
|
706
789
|
if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
|
|
@@ -822,7 +905,9 @@ function resolveReleaseArtifactPath(root, declared) {
|
|
|
822
905
|
}
|
|
823
906
|
|
|
824
907
|
function fail(message) {
|
|
908
|
+
printProblems();
|
|
825
909
|
console.error(`right-release: ${message}`);
|
|
910
|
+
if (opts.doctor) console.error("right-release: doctor stopped here: this problem prevents the checks after it from running; fix it and run the doctor again to see the rest");
|
|
826
911
|
process.exit(1);
|
|
827
912
|
}
|
|
828
913
|
|
|
@@ -838,10 +923,7 @@ function assertSharedSccachePrerequisite() {
|
|
|
838
923
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
839
924
|
const version = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
|
|
840
925
|
const valid = result.status === 0 && version && isAtLeastVersion(version.slice(1).map(Number), [0, 15, 0]);
|
|
841
|
-
if (!valid)
|
|
842
|
-
const found = version ? ` (found ${version[0]})` : "";
|
|
843
|
-
fail(`shared cache mode requires sccache >= 0.15.0${found}. Install with: cargo install sccache --locked; then verify: sccache --version`);
|
|
844
|
-
}
|
|
926
|
+
if (!valid) fail(sccacheRefusal(version?.[0]));
|
|
845
927
|
return version[0];
|
|
846
928
|
}
|
|
847
929
|
|
|
@@ -1097,38 +1179,49 @@ function commandTimeoutMs(command) {
|
|
|
1097
1179
|
: undefined;
|
|
1098
1180
|
}
|
|
1099
1181
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
1103
|
-
const scripts = pkg.scripts ?? {};
|
|
1104
|
-
assertQaBackdoorContract(root, scripts);
|
|
1182
|
+
// The workflow files of the repository, when it has any, against the hostedWorkflows policy.
|
|
1183
|
+
async function validateHostedWorkflowFiles(root, appName, hostedWorkflows, artifacts = []) {
|
|
1105
1184
|
const workflowDir = path.join(root, ".github", "workflows");
|
|
1106
|
-
if (existsSync(workflowDir))
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1185
|
+
if (!existsSync(workflowDir)) return;
|
|
1186
|
+
const workflows = (await readdir(workflowDir)).sort();
|
|
1187
|
+
if (workflows.length) await validateHostedWorkflows(root, appName, workflows, hostedWorkflows, artifacts);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function validatePackageManagerPin(pkg, appName) {
|
|
1112
1191
|
if (pkg.packageManager !== RIGHTKIT_VERSIONS.packageManager) {
|
|
1113
|
-
|
|
1192
|
+
throw new ReleaseRefusal(`${appName} packageManager must be ${RIGHTKIT_VERSIONS.packageManager}, got ${pkg.packageManager ?? "<missing>"}`, "package-manager");
|
|
1114
1193
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function validateNoParentWorkspaceScripts(pkg, appName) {
|
|
1197
|
+
const scripts = JSON.stringify(pkg.scripts ?? {});
|
|
1198
|
+
if (scripts.includes("../tools/right-release") || scripts.includes("../rightkit/packages/release")) {
|
|
1199
|
+
throw new ReleaseRefusal(`${appName} package.json must call the right-release bin, not parent-workspace release source`, "package-workspace-scripts");
|
|
1117
1200
|
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Every @rightkit/* dependency, each violation its own line of the finding.
|
|
1204
|
+
function validateRightKitPinSpecs(pkg, appName) {
|
|
1205
|
+
const found = [];
|
|
1118
1206
|
const rightKitDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
1119
1207
|
for (const [name, specifier] of Object.entries(rightKitDeps).filter(([name]) => name.startsWith("@rightkit/"))) {
|
|
1120
1208
|
if (FORBIDDEN_RIGHTKIT_SPEC.test(String(specifier))) {
|
|
1121
|
-
|
|
1209
|
+
found.push(`${appName} ${name} must come from the published npm package, not ${specifier}`);
|
|
1210
|
+
continue;
|
|
1122
1211
|
}
|
|
1123
1212
|
const allowed = RIGHTKIT_ALLOWED.get(name);
|
|
1124
1213
|
if (allowed && !rightKitVerifiedNpm.get(name)?.has(specifier) && !rightKitVersionAllowed(allowed, specifier)) {
|
|
1125
|
-
|
|
1214
|
+
found.push(`${appName} ${name} must be one of ${[...allowed].join(", ")}, got ${specifier}`);
|
|
1126
1215
|
}
|
|
1127
1216
|
}
|
|
1217
|
+
if (found.length) throw new ReleaseRefusal(found.join("\n"), "rightkit-pin-specs", found);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function validateRightKitReleaseSupported(pkg, appName) {
|
|
1128
1221
|
const releaseAllowed = RIGHTKIT_ALLOWED.get("@rightkit/release");
|
|
1129
1222
|
const releaseSpecifier = pkg.devDependencies?.["@rightkit/release"];
|
|
1130
1223
|
if (!releaseAllowed || !(rightKitVerifiedNpm.get("@rightkit/release")?.has(releaseSpecifier) || rightKitVersionAllowed(releaseAllowed, releaseSpecifier))) {
|
|
1131
|
-
|
|
1224
|
+
throw new ReleaseRefusal(`${appName} must depend on a supported @rightkit/release version in devDependencies`, "rightkit-release-supported");
|
|
1132
1225
|
}
|
|
1133
1226
|
}
|
|
1134
1227
|
|
|
@@ -1307,21 +1400,21 @@ async function signExistingRenderRefusal(root, manifest, source) {
|
|
|
1307
1400
|
async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles) {
|
|
1308
1401
|
const validator = (await loadRightKitCiModule(root, "workflows.mjs", "validateWorkflows"))?.validateWorkflows;
|
|
1309
1402
|
const renderLanes = await loadRightKitCiRenderer(root);
|
|
1310
|
-
if (!validator || !renderLanes)
|
|
1403
|
+
if (!validator || !renderLanes) throw new ReleaseRefusal(`${appName} right-git-ci-only policy cannot verify .rightgit.json workflows: @rightkit/ci is not available (install @rightkit/ci as a devDependency)`, "hosted-workflows");
|
|
1311
1404
|
const errors = [];
|
|
1312
1405
|
validator(manifest, errors);
|
|
1313
|
-
if (errors.length > 0)
|
|
1406
|
+
if (errors.length > 0) throw new ReleaseRefusal(`${appName} right-git-ci-only policy refuses the .rightgit.json workflows block: ${errors.join("; ")}`, "hosted-workflows");
|
|
1314
1407
|
let rendered;
|
|
1315
1408
|
try {
|
|
1316
1409
|
rendered = renderLanes(manifest, root);
|
|
1317
1410
|
} catch (error) {
|
|
1318
|
-
|
|
1411
|
+
throw new ReleaseRefusal(`${appName} right-git-ci-only policy cannot render the .rightgit.json workflows block: ${error.message}`, "hosted-workflows");
|
|
1319
1412
|
}
|
|
1320
1413
|
for (const file of declaredFiles) {
|
|
1321
1414
|
const expected = rendered.find((entry) => entry.filename === file)?.content;
|
|
1322
1415
|
const actual = await readFile(path.join(root, ".github", "workflows", file), "utf8");
|
|
1323
1416
|
if (typeof expected !== "string" || actual.replace(/\r\n?/g, "\n") !== expected.replace(/\r\n?/g, "\n")) {
|
|
1324
|
-
|
|
1417
|
+
throw new ReleaseRefusal(`${appName} ${file} is not byte-for-byte the @rightkit/ci render of its .rightgit.json workflows entry (run right-git sync)`, "hosted-workflows");
|
|
1325
1418
|
}
|
|
1326
1419
|
}
|
|
1327
1420
|
}
|
|
@@ -1330,7 +1423,7 @@ async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFi
|
|
|
1330
1423
|
// `contents: read`, no other permissions key exists, no secrets reference, every `uses:` is commit-pinned, and every
|
|
1331
1424
|
// checkout step sets persist-credentials: false. Steps are split on step-list indentation, not matched as free text.
|
|
1332
1425
|
function declaredWorkflowPolicyRefusal(appName, workflow, source) {
|
|
1333
|
-
const refuse = (why) =>
|
|
1426
|
+
const refuse = (why) => { throw new ReleaseRefusal(`${appName} right-git managed ${workflow} violates the declared-workflow policy: ${why}`, "hosted-workflows"); };
|
|
1334
1427
|
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
1335
1428
|
const permissionsAt = lines.flatMap((line, i) => (/^\s*permissions\s*:/.test(line) ? [i] : []));
|
|
1336
1429
|
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`");
|
|
@@ -1353,16 +1446,25 @@ function declaredWorkflowPolicyRefusal(appName, workflow, source) {
|
|
|
1353
1446
|
if (/^\s*(?:- )?uses:/m.test(lines.filter((l) => !steps.some((b) => b.includes(l))).join("\n"))) refuse("a uses: sits outside a step list");
|
|
1354
1447
|
}
|
|
1355
1448
|
|
|
1356
|
-
async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
1449
|
+
async function validateHostedWorkflows(root, appName, workflows, policy, artifacts = []) {
|
|
1450
|
+
// A repository that ships more than the released app (a monorepo's web or server pipeline) declares each workflow and why;
|
|
1451
|
+
// none may reach a secret but GITHUB_TOKEN or run release machinery (hosted-workflow-policy.mjs).
|
|
1452
|
+
if (policy !== null && typeof policy === "object" && policy.policy === DECLARED_UNRELATED_POLICY) {
|
|
1453
|
+
const texts = new Map();
|
|
1454
|
+
for (const file of workflows) texts.set(file, await readFile(path.join(root, ".github", "workflows", file), "utf8"));
|
|
1455
|
+
const refusal = declaredUnrelatedRefusal({ declaration: policy, workflows, read: (file) => texts.get(file), artifacts });
|
|
1456
|
+
if (refusal) throw new ReleaseRefusal(`${appName} ${DECLARED_UNRELATED_POLICY} hosted workflows refused: ${refusal}`, "hosted-workflows");
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1357
1459
|
if (policy !== "right-git-ci-only") {
|
|
1358
|
-
|
|
1460
|
+
throw new ReleaseRefusal(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")} (set hostedWorkflows to "right-git-ci-only", or to { policy: "${DECLARED_UNRELATED_POLICY}", files: { <file>: <reason> } } when they are a pipeline unrelated to the released app)`, "hosted-workflows");
|
|
1359
1461
|
}
|
|
1360
1462
|
const manifestPath = path.join(root, ".rightgit.json");
|
|
1361
1463
|
let manifest;
|
|
1362
1464
|
try {
|
|
1363
1465
|
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
1364
1466
|
} catch {
|
|
1365
|
-
|
|
1467
|
+
throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires a valid .rightgit.json`, "hosted-workflows");
|
|
1366
1468
|
}
|
|
1367
1469
|
const lanes = manifest?.lanes;
|
|
1368
1470
|
// Mirrors the `lanes` enum in @rightkit/ci manifest.schema.json.
|
|
@@ -1374,29 +1476,29 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
|
1374
1476
|
|| !(lanes.includes("ci") || lanes.includes("swift-check"))
|
|
1375
1477
|
|| new Set(lanes).size !== lanes.length
|
|
1376
1478
|
|| lanes.some((lane) => !safeLanes.has(lane))) {
|
|
1377
|
-
|
|
1479
|
+
throw new ReleaseRefusal(`${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"`, "hosted-workflows");
|
|
1378
1480
|
}
|
|
1379
1481
|
// Workflows declared under the manifest's `workflows` block (@rightkit/ci workflows.mjs: explicit `file`,
|
|
1380
1482
|
// else `<name>.yml`) are managed files too, in addition to the lane files.
|
|
1381
1483
|
const declaredWorkflows = manifest?.workflows;
|
|
1382
1484
|
if (declaredWorkflows !== undefined && (declaredWorkflows === null || typeof declaredWorkflows !== "object" || Array.isArray(declaredWorkflows))) {
|
|
1383
|
-
|
|
1485
|
+
throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires .rightgit.json workflows to be an object`, "hosted-workflows");
|
|
1384
1486
|
}
|
|
1385
1487
|
const declaredFiles = Object.entries(declaredWorkflows ?? {}).map(([name, config]) => {
|
|
1386
|
-
if (config === null || typeof config !== "object" || Array.isArray(config))
|
|
1488
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires .rightgit.json workflows.${name} to be an object`, "hosted-workflows");
|
|
1387
1489
|
const file = typeof config?.file === "string" ? config.file : `${name}.yml`;
|
|
1388
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,60}\.yml$/.test(file))
|
|
1490
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,60}\.yml$/.test(file)) throw new ReleaseRefusal(`${appName} right-git-ci-only policy refuses workflow file name ${JSON.stringify(file)} declared by .rightgit.json`, "hosted-workflows");
|
|
1389
1491
|
return file;
|
|
1390
1492
|
});
|
|
1391
1493
|
const expectedWorkflows = [...new Set([...lanes.map((lane) => `${lane}.yml`), ...declaredFiles])].sort();
|
|
1392
1494
|
if (JSON.stringify(workflows) !== JSON.stringify(expectedWorkflows)) {
|
|
1393
|
-
|
|
1495
|
+
throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires workflows declared by .rightgit.json; expected ${expectedWorkflows.join(", ")}, got: ${workflows.join(", ")}`, "hosted-workflows");
|
|
1394
1496
|
}
|
|
1395
1497
|
if (declaredFiles.length > 0) await validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles);
|
|
1396
1498
|
for (const workflow of expectedWorkflows) {
|
|
1397
1499
|
const source = await readFile(path.join(root, ".github", "workflows", workflow), "utf8");
|
|
1398
1500
|
if (!source.startsWith("# Managed by right-git")) {
|
|
1399
|
-
|
|
1501
|
+
throw new ReleaseRefusal(`${appName} ${workflow} is not marked as right-git managed`, "hosted-workflows");
|
|
1400
1502
|
}
|
|
1401
1503
|
if (declaredFiles.includes(workflow)) declaredWorkflowPolicyRefusal(appName, workflow, source);
|
|
1402
1504
|
const jobSpan = (jobName) => {
|
|
@@ -1483,7 +1585,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
|
1483
1585
|
const reason = manifest?.signExisting && typeof manifest.signExisting === "object"
|
|
1484
1586
|
? (signExistingRefusal(source) ?? await signExistingRenderRefusal(root, manifest, source))
|
|
1485
1587
|
: "the manifest declares no signExisting";
|
|
1486
|
-
if (reason !== null)
|
|
1588
|
+
if (reason !== null) throw new ReleaseRefusal(`${appName} right-git managed sign-existing.yml is not the protected sign-existing shape: ${reason}`, "hosted-workflows");
|
|
1487
1589
|
protectedSignExisting = true;
|
|
1488
1590
|
}
|
|
1489
1591
|
// @rightkit/ci `swift.privatePackages` credential: allowed only in the workflow/job pairs and on the
|
|
@@ -1526,7 +1628,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
|
1526
1628
|
|| refusedSecrets.length > 0) {
|
|
1527
1629
|
const reasons = [...new Set(refusedSecrets)];
|
|
1528
1630
|
const detail = reasons.length > 10 ? [...reasons.slice(0, 10), `${reasons.length - 10} more`] : reasons;
|
|
1529
|
-
|
|
1631
|
+
throw new ReleaseRefusal(`${appName} right-git managed ${workflow} requests release-capable permissions or secrets${detail.length > 0 ? `: ${detail.join("; ")}` : ""}`, "hosted-workflows");
|
|
1530
1632
|
}
|
|
1531
1633
|
}
|
|
1532
1634
|
}
|