@rightkit/release 0.6.3 → 0.6.4
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 +37 -5
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +4 -4
- package/cli/right-release.mjs +3 -2
- package/deterministic-scans.mjs +21 -10
- package/development.mjs +3 -2
- package/hosted-workflow-policy.mjs +111 -0
- 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/package.json +1 -1
- 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 +187 -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 +244 -197
- package/rightapps-register.mjs +3 -1
- package/rightkit-drift.mjs +1 -1
- package/rightkit-ownership.json +15 -5
- package/rightkit-versions.json +43 -32
- 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,15 @@ 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";
|
|
59
62
|
|
|
60
63
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
61
64
|
const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
|
|
@@ -128,11 +131,42 @@ const opts = {
|
|
|
128
131
|
dryRun: false,
|
|
129
132
|
skipChecks: false,
|
|
130
133
|
doctor: false,
|
|
134
|
+
gates: false,
|
|
131
135
|
tier: null,
|
|
132
136
|
resume: false,
|
|
133
137
|
resumeFrom: null,
|
|
134
138
|
};
|
|
135
139
|
|
|
140
|
+
// E01: every independent problem of one run is collected and reported together; the doctor and the build share it.
|
|
141
|
+
// `check` / `reject` record a finding and continue; a check whose input comes from a failed one is not run on that bad input
|
|
142
|
+
// but recorded as `BLOCKED by <check>`; a throw inside a check is a finding, never a crash. `fail` stays for what cannot
|
|
143
|
+
// continue (it prints what was collected first). The build stops at one checkpoint (before the release lock, install, checks and
|
|
144
|
+
// compile) when any finding exists, so no build step or other side effect ever follows a failed check.
|
|
145
|
+
const problems = []; // { name, detail }
|
|
146
|
+
const failedChecks = new Set();
|
|
147
|
+
function reject(name, detail) {
|
|
148
|
+
failedChecks.add(name);
|
|
149
|
+
problems.push({ name, detail });
|
|
150
|
+
}
|
|
151
|
+
function rejectBlocked(name, blockers, what = name) {
|
|
152
|
+
const blocker = blockers.find((candidate) => failedChecks.has(candidate));
|
|
153
|
+
if (!blocker) return false;
|
|
154
|
+
reject(name, `${what} BLOCKED by ${blocker}`);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
async function check(name, after, run) {
|
|
158
|
+
if (rejectBlocked(name, after)) return { ok: false };
|
|
159
|
+
try {
|
|
160
|
+
return { ok: true, value: await run() };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
reject(name, error instanceof Error ? error.message : `${name} threw a non-Error value: ${String(error)}`);
|
|
163
|
+
return { ok: false };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const printProblems = () => {
|
|
167
|
+
for (const problem of problems.splice(0)) console.error(`right-release: ${problem.detail}`);
|
|
168
|
+
};
|
|
169
|
+
|
|
136
170
|
for (let i = 0; i < args.length; i++) {
|
|
137
171
|
const arg = args[i];
|
|
138
172
|
if (arg === "--") continue;
|
|
@@ -145,6 +179,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
145
179
|
else if (arg === "--skip-checks") opts.skipChecks = true;
|
|
146
180
|
else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
|
|
147
181
|
else if (arg === "--doctor") opts.doctor = true;
|
|
182
|
+
else if (arg === "--gates") opts.gates = true;
|
|
148
183
|
else if (arg === "--admission") opts.admission = true;
|
|
149
184
|
else if (arg === "--resume") opts.resume = true;
|
|
150
185
|
else if (arg === "--resume-from") { opts.resume = true; opts.resumeFrom = args[++i]; }
|
|
@@ -158,6 +193,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
158
193
|
else usage(2, `unknown argument: ${arg}`);
|
|
159
194
|
}
|
|
160
195
|
|
|
196
|
+
if (opts.gates && !opts.doctor) usage(2, "--gates is valid only with --doctor");
|
|
161
197
|
if (opts.upload) fail("combined build+upload was removed; run right-release build, then right-release upload --release <id> --tier patch|update");
|
|
162
198
|
if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
|
|
163
199
|
const MAC_RESUME_PHASES = ["prepackage", "sign", "package", "post-sign", "harden"];
|
|
@@ -179,9 +215,12 @@ if (opts.doctor) {
|
|
|
179
215
|
if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
|
|
180
216
|
doctorInvocation = { repoRoot, commit };
|
|
181
217
|
}
|
|
182
|
-
|
|
218
|
+
let config;
|
|
219
|
+
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
220
|
if (!config) usage(2, `config did not export default: ${configPath}`);
|
|
184
221
|
if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<missing>"} (expected 1)`);
|
|
222
|
+
// Field-level config refusals (updater.keyFile: keys come from the OS credential provider) are listed with every other finding.
|
|
223
|
+
for (const refusal of releaseConfigFieldProblems(config)) reject(refusal.name, refusal.detail);
|
|
185
224
|
|
|
186
225
|
// Hosted-workflow policy patterns: defined before the top-level await below uses them.
|
|
187
226
|
const SIGN_EXISTING_SECRETS = new Set(["APPLE_CERTIFICATE_BASE64", "APPLE_CERTIFICATE_PASSWORD", "APPLE_KEYCHAIN_PASSWORD"]);
|
|
@@ -205,7 +244,7 @@ const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
|
|
|
205
244
|
// (build-release.mjs). This worker, a resume and the upload consume only that admitted snapshot, through the release's own
|
|
206
245
|
// state root and admission record; a snapshot from anywhere else is refused. The standalone doctor resolves live.
|
|
207
246
|
const rightKitSnapshotFile = process.env[SNAPSHOT_ENV] || null;
|
|
208
|
-
let rightKitResolution;
|
|
247
|
+
let rightKitResolution = null; // null when the registry resolution failed (recorded as the `rightkit-registry` finding)
|
|
209
248
|
try {
|
|
210
249
|
const lockInventory = () => {
|
|
211
250
|
try {
|
|
@@ -237,10 +276,12 @@ try {
|
|
|
237
276
|
admission: opts.doctor && opts.admission,
|
|
238
277
|
});
|
|
239
278
|
} catch (error) {
|
|
240
|
-
|
|
241
|
-
|
|
279
|
+
rightKitResolution = null;
|
|
280
|
+
reject("rightkit-registry", error instanceof RightKitRegistryError
|
|
281
|
+
? `${config.app ?? path.basename(root)} ${error.recoverable ? "RECOVERABLE " : ""}${error.message}`
|
|
282
|
+
: `RightKit registry resolution threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
242
283
|
}
|
|
243
|
-
{
|
|
284
|
+
if (rightKitResolution) {
|
|
244
285
|
const { newer, tasks, warnings, notes } = rightKitResolution.freshness;
|
|
245
286
|
const out = opts.doctor ? console.log : console.error;
|
|
246
287
|
for (const line of newer) out(`right-release: newer RightKit version (informational): ${line}`);
|
|
@@ -248,88 +289,127 @@ try {
|
|
|
248
289
|
for (const line of warnings) console.error(`right-release: WARNING ${line}`);
|
|
249
290
|
for (const line of notes) console.error(`right-release: note ${line}`);
|
|
250
291
|
}
|
|
251
|
-
rightKitVerifiedNpm = rightKitResolution.verified.npm;
|
|
292
|
+
if (rightKitResolution) rightKitVerifiedNpm = rightKitResolution.verified.npm;
|
|
293
|
+
// The contracts below are independent of each other; the two that read the proven RightKit versions need the registry resolution.
|
|
252
294
|
// A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
295
|
+
await check("rightkit-package-contract", ["rightkit-registry"], async () => {
|
|
296
|
+
if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
|
|
297
|
+
await validateRightKitPackageContract(root, config.app, config.hostedWorkflows, releaseArtifactPaths(config));
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
await check("rightkit-cargo-contract", ["rightkit-registry"], () => {
|
|
301
|
+
validateRightKitCargoContract(root, mergeVerifiedVersions(RIGHTKIT_CARGO_ALLOWED, rightKitResolution.verified.cargo), config.app ?? path.basename(root));
|
|
302
|
+
});
|
|
257
303
|
// One standard dev profile and panic = "unwind" in every app (templates/cargo-profiles.toml).
|
|
258
|
-
assertCargoProfileContract(root, config.app ?? path.basename(root));
|
|
304
|
+
await check("cargo-profile-contract", [], () => assertCargoProfileContract(root, config.app ?? path.basename(root)));
|
|
259
305
|
// 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 });
|
|
306
|
+
await check("qa-adoption", [], () => assertQaAdoption({ appRoot: root, app: config.app }));
|
|
261
307
|
let target = config.targets?.[opts.platform];
|
|
262
308
|
// Declarative winPackage / macPackage (#345) expand to the same hook shape every gate below already checks.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
309
|
+
// When it fails, `target` is not usable: every check that reads it is reported as BLOCKED by `target`.
|
|
310
|
+
await check("target", [], () => {
|
|
311
|
+
let expanded;
|
|
312
|
+
try { expanded = expandPackageRecipeTarget(target, { platform: opts.platform, appRoot: root, config }); } catch (error) { throw new Error(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
|
|
313
|
+
if (!expanded) throw new Error(`${config.app ?? "app"} has no ${opts.platform} release target`);
|
|
314
|
+
target = expanded;
|
|
315
|
+
});
|
|
316
|
+
const legalCheck = await check("legal-contract", [], () => (config.legal
|
|
266
317
|
? 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
|
-
}
|
|
318
|
+
: null));
|
|
319
|
+
const legalContract = legalCheck.ok ? legalCheck.value : null;
|
|
271
320
|
let macSigning; // validated macOS sign.prePackageFiles / sign.nestedCode (mac only)
|
|
272
321
|
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
322
|
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
323
|
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
324
|
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
|
-
|
|
325
|
+
const app = config.app ?? "app";
|
|
326
|
+
// Everything below reads the expanded target. Each independent refusal is its own finding; a check that needs the output of
|
|
327
|
+
// another (mac signing reads the sidecar config, the iOS prepare check reads the iOS spec) is BLOCKED by it, never run on it.
|
|
328
|
+
if (!rejectBlocked("target-checks", ["target"], "the target checks (signed, mediaSidecar, externalBin, publishBlocked and the platform signing and package contract)")) {
|
|
329
|
+
if (target.signed !== true) reject("target-signed", `${app} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
|
|
330
|
+
if (target.mediaSidecar !== undefined) {
|
|
331
|
+
if (opts.platform !== "mac" && opts.platform !== "win") reject("media-sidecar", `${app} ${opts.platform} does not support mediaSidecar (mac and win only)`);
|
|
332
|
+
else await check("media-sidecar", [], () => {
|
|
333
|
+
try { mediaSidecarConfig = validateMediaSidecarConfig(target.mediaSidecar, { platform: opts.platform }); } catch (error) { throw new Error(`${app} ${opts.platform} ${error.message}`); }
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (target.externalBin !== undefined) {
|
|
337
|
+
if (opts.platform !== "mac" && opts.platform !== "win") reject("external-bin", `${app} ${opts.platform} does not support externalBin (mac and win only)`);
|
|
338
|
+
else await check("external-bin", ["media-sidecar"], () => {
|
|
339
|
+
try { externalBinConfig = validateExternalBinConfig(target.externalBin, { platform: opts.platform, reservedNames: mediaSidecarConfig?.tools ?? [] }); } catch (error) { throw new Error(`${app} ${opts.platform} ${error.message}`); }
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (opts.platform === "win") {
|
|
343
|
+
const isNsis = target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT;
|
|
344
|
+
const isPortable = target.signingContract === WINDOWS_PORTABLE_SIGNING_CONTRACT;
|
|
345
|
+
const isMsix = target.signingContract === MSIX_SIGNING_CONTRACT;
|
|
346
|
+
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}`);
|
|
347
|
+
if (isMsix) {
|
|
348
|
+
await check("win-msix", [], () => assertValidMsixTarget(target, app));
|
|
349
|
+
} else {
|
|
350
|
+
// The NSIS and portable rules below depend on which contract the target declares.
|
|
351
|
+
if (failedChecks.has("win-signing-contract")) reject("win-contract-rules", "the win NSIS and portable rules (sign.files, distinct raw EXE and installer files) BLOCKED by win-signing-contract");
|
|
352
|
+
if (!target.prePackage?.cmd) reject("win-prepackage", `${app} win must declare prePackage for the raw EXE build`);
|
|
353
|
+
const rawFiles = target.sign?.prePackageFiles;
|
|
354
|
+
if (!rawFiles?.length) reject("win-sign-prepackage-files", `${app} win must declare at least one sign.prePackageFiles raw EXE`);
|
|
355
|
+
else {
|
|
356
|
+
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`);
|
|
357
|
+
if (isNsis && rawFiles.length !== 1) reject("win-nsis-raw-exe", `${app} win NSIS must declare exactly one sign.prePackageFiles raw EXE`);
|
|
358
|
+
}
|
|
359
|
+
if (isNsis && !target.sign?.files?.length) reject("win-nsis-sign-files", `${app} win NSIS must declare sign.files for installer signing`);
|
|
360
|
+
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`);
|
|
361
|
+
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`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (opts.platform === "mac") {
|
|
365
|
+
if (target.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT) reject("mac-signing-contract", `${app} mac must declare signingContract: ${MACOS_PORTABLE_SIGNING_CONTRACT}`);
|
|
366
|
+
if (!target.prePackage?.cmd) reject("mac-prepackage", `${app} mac must declare prePackage to materialize Developer ID signing files`);
|
|
367
|
+
if (!target.sign?.prePackageFiles?.length) reject("mac-sign-prepackage-files", `${app} mac must declare at least one sign.prePackageFiles`);
|
|
368
|
+
else if (!rejectBlocked("mac-signing", ["media-sidecar", "external-bin"], `${app} mac signing config validation (it reads the sidecar and externalBin configs)`)) {
|
|
369
|
+
await check("mac-signing", [], () => {
|
|
370
|
+
const macSignConfig = mediaSidecarConfig || externalBinConfig
|
|
371
|
+
? { ...target.sign, nestedCode: { ...(mediaSidecarConfig ? mediaSidecarNestedCode(mediaSidecarConfig) : {}), ...(externalBinConfig ? externalBinNestedCode(externalBinConfig) : {}), ...(target.sign.nestedCode ?? {}) } }
|
|
372
|
+
: target.sign;
|
|
373
|
+
try { macSigning = validateMacSigningConfig(macSignConfig, { root }); } catch (error) { throw new Error(`${app} mac ${error.message}`); }
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (!target.notarize?.file) reject("mac-notarize-file", `${app} mac must declare notarize.file`);
|
|
377
|
+
else {
|
|
378
|
+
if (target.notarize.appBeforePackage && !target.notarize.app) reject("mac-notarize-app", `${app} mac notarize.appBeforePackage requires notarize.app`);
|
|
379
|
+
if (target.notarize.staple === false && (target.notarize.app || /\.(dmg|pkg)$/i.test(target.notarize.file))) {
|
|
380
|
+
reject("mac-notarize-staple", `${app} mac notarize.staple: false applies only to an archive of bare executables (no notarize.app, not a .dmg/.pkg)`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
await check("mac-swift-dmg", [], () => {
|
|
384
|
+
const swiftDmgError = swiftMacDmgConfigError({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: app });
|
|
385
|
+
if (swiftDmgError) throw new Error(swiftDmgError);
|
|
386
|
+
});
|
|
387
|
+
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)`)) {
|
|
388
|
+
await check("mac-dmg-spec", [], () => {
|
|
389
|
+
try {
|
|
390
|
+
if (typeof target.dmg.spec !== "string" || !target.dmg.spec) throw new Error("dmg.spec must be a path string");
|
|
391
|
+
const specOptions = readDmgSpec(path.resolve(root, target.dmg.spec), { app: target.notarize.app });
|
|
392
|
+
for (const asset of [specOptions.background, specOptions.volumeIcon]) if (asset && !existsSync(asset)) throw new Error(`referenced file not found: ${asset}`);
|
|
393
|
+
} catch (error) { throw new Error(`${app} mac targets.mac.dmg.spec: ${error.message}`); }
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (opts.platform === "android") {
|
|
398
|
+
await check("android-target", [], () => {
|
|
399
|
+
try { androidTarget = validateAndroidTarget(target, { root }); } catch (error) { throw new Error(`${app} android ${error.message}`); }
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (opts.platform === "ios") {
|
|
403
|
+
if (target.recipe?.kind !== "iosPackage") reject("ios-target", `${app} ios must declare targets.ios.iosPackage; there is no hand-written iOS package script`);
|
|
404
|
+
else {
|
|
405
|
+
iosSpec = target.recipe.spec;
|
|
406
|
+
if (nativeIos) await check("ios-prepare-manifest", [], () => {
|
|
407
|
+
try { assertNativeIosPrepareHasManifest(iosSpec, config, root); } catch (error) { throw new Error(`${app} ios ${error.message}`); }
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (opts.upload && target.publishBlocked) reject("publish-blocked", `${app} ${opts.platform} publish blocked: ${target.publishBlocked}`);
|
|
331
412
|
}
|
|
332
|
-
if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
|
|
333
413
|
/**
|
|
334
414
|
* Assemble preflight inputs from the app's own files. Kept here (not in
|
|
335
415
|
* preflight.mjs) so the check module stays free of release.mjs's config shape.
|
|
@@ -377,24 +457,25 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
|
|
|
377
457
|
}
|
|
378
458
|
|
|
379
459
|
if (opts.doctor) {
|
|
380
|
-
|
|
460
|
+
// Every check below is independent unless it names what it needs; a failed input blocks (never feeds) what reads it.
|
|
461
|
+
const inputs = await check("build-inputs", ["target"], () => resolveReleaseBuildInputs({
|
|
381
462
|
repoRoot: doctorInvocation.repoRoot,
|
|
382
463
|
appRoot: root,
|
|
383
464
|
configPath,
|
|
384
465
|
config,
|
|
385
466
|
target,
|
|
467
|
+
}));
|
|
468
|
+
await check("dirty-inputs", ["build-inputs"], () => {
|
|
469
|
+
const dirtyInputs = dirtyBuildInputs(doctorInvocation.repoRoot, inputs.value.buildInputs, doctorInvocation.commit);
|
|
470
|
+
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
471
|
});
|
|
387
|
-
const dirtyInputs = dirtyBuildInputs(doctorInvocation.repoRoot, buildInputs, doctorInvocation.commit);
|
|
388
|
-
if (dirtyInputs.length > 0) {
|
|
389
|
-
fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
|
|
390
|
-
}
|
|
391
472
|
console.log(`right-release ${VERSION}`);
|
|
392
473
|
console.log(`config: ${configPath}`);
|
|
393
474
|
console.log(`app: ${config.app}`);
|
|
394
475
|
console.log(`platform: ${opts.platform}`);
|
|
395
476
|
console.log(`tier: ${opts.tier ?? "<required for release/publish>"}`);
|
|
396
477
|
console.log(`packageManager: ${config.packageManager}`);
|
|
397
|
-
console.log(`rightkit-registry: ${rightKitResolution.summary}`);
|
|
478
|
+
if (rightKitResolution) console.log(`rightkit-registry: ${rightKitResolution.summary}`);
|
|
398
479
|
console.log(`workdir: ${workdir}`);
|
|
399
480
|
console.log(`hardeningscan: ${HARDENING_SCAN}`);
|
|
400
481
|
if (sccacheVersion) console.log(`sccache: ${sccacheVersion}`);
|
|
@@ -403,125 +484,79 @@ if (opts.doctor) {
|
|
|
403
484
|
console.log(`legalAcceptance: ${legalContract.acceptanceVersion}`);
|
|
404
485
|
console.log(`legalManifestSha256: ${legalContract.manifestSha256}`);
|
|
405
486
|
}
|
|
406
|
-
if (
|
|
407
|
-
|
|
487
|
+
if (!failedChecks.has("target")) {
|
|
488
|
+
if (target.sign?.files?.length) console.log(`sign: ${target.sign.files.join(", ")}`);
|
|
489
|
+
if (target.publishBlocked) console.log(`publishBlocked: ${target.publishBlocked}`);
|
|
490
|
+
}
|
|
408
491
|
const managedRoot = process.env.RIGHTKIT_MANAGED_ROOT;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
492
|
+
await check("artifact-storage", [], () => {
|
|
493
|
+
const installerCopyFindings = findInstallerCopyScripts({
|
|
494
|
+
appRoot: root,
|
|
495
|
+
workspaceRoot: process.env.RIGHTKIT_WORKSPACE_ROOT ?? path.resolve(doctorInvocation.repoRoot, ".."),
|
|
496
|
+
});
|
|
497
|
+
const storageFindings = [
|
|
498
|
+
...installerCopyFindings,
|
|
499
|
+
...findLargeRepositorySubdirectories({ appRoot: root, managedRoot, declared: config.storage?.declaredStores ?? [], thresholdBytes: Number(process.env.RIGHTKIT_ARTIFACT_STORAGE_THRESHOLD_BYTES ?? DEFAULT_STORAGE_THRESHOLD_BYTES) }),
|
|
500
|
+
];
|
|
501
|
+
if (storageFindings.length) {
|
|
502
|
+
console.log("artifact-storage: report-only storage findings detected:");
|
|
503
|
+
for (const finding of storageFindings) console.log(` - ${finding.path}: ${finding.reason}`);
|
|
504
|
+
throw new Error(`artifact-storage found ${storageFindings.length} repository storage finding(s); move outputs to RIGHT_RELEASE_ARTIFACT_DIR`);
|
|
505
|
+
} else console.log("artifact-storage: no installer-copy scripts detected");
|
|
412
506
|
});
|
|
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
507
|
|
|
423
508
|
// 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
|
-
|
|
509
|
+
// box could finish a build; these checks do. The machine preflight reads the resolved build inputs, so it is
|
|
510
|
+
// BLOCKED when those could not be resolved.
|
|
511
|
+
let failures = [];
|
|
512
|
+
const machine = await check("preflight", ["target", "build-inputs"], () => {
|
|
513
|
+
const { nativeLayout: resolvedNativeLayout, cargoLockPaths: pinnedCargoLocks } = inputs.value;
|
|
514
|
+
return releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout, pinnedCargoLocks });
|
|
515
|
+
});
|
|
516
|
+
if (machine.ok) {
|
|
517
|
+
console.log("preflight:");
|
|
518
|
+
console.log(formatPreflight(machine.value));
|
|
519
|
+
failures = preflightFailures(machine.value);
|
|
520
|
+
}
|
|
521
|
+
// --gates: run the build's pre-build source gates (release-switch scan, feature closure, release dependencies,
|
|
522
|
+
// devtools, production mode) over this app and report each finding as a FAIL row. They read the expanded target
|
|
523
|
+
// (and, on iOS, the iOS spec), so a failed one of those blocks them.
|
|
524
|
+
let gateFindings = [];
|
|
525
|
+
if (opts.gates) {
|
|
526
|
+
const blocker = ["target", ...(opts.platform === "ios" ? ["ios-target"] : [])].find((name) => failedChecks.has(name));
|
|
527
|
+
gateFindings = blocker
|
|
528
|
+
? [{ name: "prebuild-gates", detail: `BLOCKED by ${blocker}` }]
|
|
529
|
+
: evaluatePrebuildGates({ root, config, target, platform: opts.platform, iosSpec, nativeIos, nativeAssembly, nativeFinalizer, strict: !opts.dryRun }).findings;
|
|
530
|
+
}
|
|
531
|
+
const configProblems = problems.length;
|
|
532
|
+
// The config findings (stderr, as every early refusal always was) and the gate rows (stdout) of this one run.
|
|
533
|
+
printProblems();
|
|
534
|
+
if (gateFindings.length) {
|
|
535
|
+
console.log(gateFindings.map((finding) => ` [FAIL] gate:${finding.name}: ${finding.detail}`).join("\n"));
|
|
536
|
+
}
|
|
537
|
+
const total = failures.length + gateFindings.length + configProblems;
|
|
538
|
+
if (total > 0) {
|
|
539
|
+
console.error(`right-release: preflight found ${total} blocking problem(s); the build would fail on these`);
|
|
540
|
+
process.exit(1);
|
|
431
541
|
}
|
|
432
542
|
process.exit(0);
|
|
433
543
|
}
|
|
434
544
|
|
|
435
545
|
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); }
|
|
546
|
+
// One checkpoint for the build: every config finding of this run and every source-gate finding are reported together, and
|
|
547
|
+
// nothing below (lock, install, checks, compile, signing) runs while any exists.
|
|
548
|
+
// Source-level gates (release-switch scan, devtools, feature closure, release dependencies, production mode) live in
|
|
549
|
+
// prebuild-gates.mjs so `doctor --gates` runs the exact same code in the same order. They read the expanded target (and the
|
|
550
|
+
// iOS spec), so a failed one of those blocks them.
|
|
551
|
+
const prebuildBlocker = ["target", ...(opts.platform === "ios" ? ["ios-target"] : [])].find((name) => failedChecks.has(name));
|
|
552
|
+
const { findings: prebuildFindings, devtoolsAllowance, inventoryText: releaseSwitchInventoryText } = prebuildBlocker
|
|
553
|
+
? { findings: [{ name: "prebuild-gates", detail: `BLOCKED by ${prebuildBlocker}` }], devtoolsAllowance: { mode: "denied" }, inventoryText: null }
|
|
554
|
+
: evaluatePrebuildGates({ root, config, target, platform: opts.platform, iosSpec, nativeIos, nativeAssembly, nativeFinalizer, strict: !opts.dryRun });
|
|
555
|
+
if (problems.length || prebuildFindings.length) {
|
|
556
|
+
printProblems();
|
|
557
|
+
for (const finding of prebuildFindings) console.error(`right-release: ${finding.detail.startsWith("BLOCKED by ") ? `gate ${finding.name}: ` : ""}${finding.detail}`);
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
525
560
|
|
|
526
561
|
const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
|
|
527
562
|
const mode = "package";
|
|
@@ -700,7 +735,8 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
|
|
|
700
735
|
if (target.postSign) await runCommands(target.postSign, root);
|
|
701
736
|
const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => resolveReleaseArtifactPath(root, artifact.file)))];
|
|
702
737
|
if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
|
|
703
|
-
|
|
738
|
+
// The key comes from the OS credential provider inside sign-updater; targets.win.updater.keyFile is refused at config validation and never forwarded.
|
|
739
|
+
await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
|
|
704
740
|
}
|
|
705
741
|
|
|
706
742
|
if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
|
|
@@ -822,7 +858,9 @@ function resolveReleaseArtifactPath(root, declared) {
|
|
|
822
858
|
}
|
|
823
859
|
|
|
824
860
|
function fail(message) {
|
|
861
|
+
printProblems();
|
|
825
862
|
console.error(`right-release: ${message}`);
|
|
863
|
+
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
864
|
process.exit(1);
|
|
827
865
|
}
|
|
828
866
|
|
|
@@ -1097,7 +1135,7 @@ function commandTimeoutMs(command) {
|
|
|
1097
1135
|
: undefined;
|
|
1098
1136
|
}
|
|
1099
1137
|
|
|
1100
|
-
async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
|
|
1138
|
+
async function validateRightKitPackageContract(root, appName, hostedWorkflows, artifacts = []) {
|
|
1101
1139
|
const packageJsonPath = path.join(root, "package.json");
|
|
1102
1140
|
const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
1103
1141
|
const scripts = pkg.scripts ?? {};
|
|
@@ -1106,7 +1144,7 @@ async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
|
|
|
1106
1144
|
if (existsSync(workflowDir)) {
|
|
1107
1145
|
const workflows = (await readdir(workflowDir)).sort();
|
|
1108
1146
|
if (workflows.length) {
|
|
1109
|
-
await validateHostedWorkflows(root, appName ?? pkg.name ?? "app", workflows, hostedWorkflows);
|
|
1147
|
+
await validateHostedWorkflows(root, appName ?? pkg.name ?? "app", workflows, hostedWorkflows, artifacts);
|
|
1110
1148
|
}
|
|
1111
1149
|
}
|
|
1112
1150
|
if (pkg.packageManager !== RIGHTKIT_VERSIONS.packageManager) {
|
|
@@ -1353,9 +1391,18 @@ function declaredWorkflowPolicyRefusal(appName, workflow, source) {
|
|
|
1353
1391
|
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
1392
|
}
|
|
1355
1393
|
|
|
1356
|
-
async function validateHostedWorkflows(root, appName, workflows, policy) {
|
|
1394
|
+
async function validateHostedWorkflows(root, appName, workflows, policy, artifacts = []) {
|
|
1395
|
+
// A repository that ships more than the released app (a monorepo's web or server pipeline) declares each workflow and why;
|
|
1396
|
+
// none may reach a secret but GITHUB_TOKEN or run release machinery (hosted-workflow-policy.mjs).
|
|
1397
|
+
if (policy !== null && typeof policy === "object" && policy.policy === DECLARED_UNRELATED_POLICY) {
|
|
1398
|
+
const texts = new Map();
|
|
1399
|
+
for (const file of workflows) texts.set(file, await readFile(path.join(root, ".github", "workflows", file), "utf8"));
|
|
1400
|
+
const refusal = declaredUnrelatedRefusal({ declaration: policy, workflows, read: (file) => texts.get(file), artifacts });
|
|
1401
|
+
if (refusal) fail(`${appName} ${DECLARED_UNRELATED_POLICY} hosted workflows refused: ${refusal}`);
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1357
1404
|
if (policy !== "right-git-ci-only") {
|
|
1358
|
-
fail(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")}`);
|
|
1405
|
+
fail(`${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)`);
|
|
1359
1406
|
}
|
|
1360
1407
|
const manifestPath = path.join(root, ".rightgit.json");
|
|
1361
1408
|
let manifest;
|
package/rightapps-register.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { RIGHT_SUITE_API_ORIGIN } from "./suite-api.generated.mjs";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_API_BASE = RIGHT_SUITE_API_ORIGIN;
|
|
2
4
|
const REGISTRATION_ROUTES = new Set([
|
|
3
5
|
"/v1/admin/apps/patches",
|
|
4
6
|
"/v1/admin/apps/releases",
|