@rightkit/release 0.6.4 → 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 +30 -0
- 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 +64 -25
- package/build-release.mjs +81 -21
- package/cli/right-release.mjs +37 -4
- package/deterministic-scans.mjs +21 -5
- package/inventory-check.mjs +12 -1
- package/ownership-dependency-allowlist.json +2 -0
- package/package.json +5 -3
- package/prebuild-gates.mjs +7 -3
- package/release.mjs +113 -58
- package/rightkit-versions.json +10 -8
package/release.mjs
CHANGED
|
@@ -59,6 +59,8 @@ import { releaseSwitchInputs } from "./ios-build-context.mjs";
|
|
|
59
59
|
import { productionBuildEnv, productionModeProblems } from "./production-mode.mjs";
|
|
60
60
|
import { evaluatePrebuildGates } from "./prebuild-gates.mjs";
|
|
61
61
|
import { releaseConfigFieldProblems } from "./release-config.mjs";
|
|
62
|
+
import { describeRegistryError, formatNotRun, runAdmission } from "./admission.mjs";
|
|
63
|
+
import { sccacheRefusal } from "./admission-host.mjs";
|
|
62
64
|
|
|
63
65
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
64
66
|
const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
|
|
@@ -132,6 +134,8 @@ const opts = {
|
|
|
132
134
|
skipChecks: false,
|
|
133
135
|
doctor: false,
|
|
134
136
|
gates: false,
|
|
137
|
+
inspect: false,
|
|
138
|
+
strict: false,
|
|
135
139
|
tier: null,
|
|
136
140
|
resume: false,
|
|
137
141
|
resumeFrom: null,
|
|
@@ -139,19 +143,37 @@ const opts = {
|
|
|
139
143
|
|
|
140
144
|
// E01: every independent problem of one run is collected and reported together; the doctor and the build share it.
|
|
141
145
|
// `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
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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.
|
|
145
150
|
const problems = []; // { name, detail }
|
|
151
|
+
const notRunLines = []; // what a failed check kept from running; reported beside the findings, never counted as one
|
|
146
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"]);
|
|
147
157
|
function reject(name, detail) {
|
|
148
158
|
failedChecks.add(name);
|
|
149
|
-
problems.push({ name, detail });
|
|
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
|
+
}
|
|
150
170
|
}
|
|
151
171
|
function rejectBlocked(name, blockers, what = name) {
|
|
152
172
|
const blocker = blockers.find((candidate) => failedChecks.has(candidate));
|
|
153
173
|
if (!blocker) return false;
|
|
154
|
-
|
|
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`);
|
|
155
177
|
return true;
|
|
156
178
|
}
|
|
157
179
|
async function check(name, after, run) {
|
|
@@ -159,12 +181,14 @@ async function check(name, after, run) {
|
|
|
159
181
|
try {
|
|
160
182
|
return { ok: true, value: await run() };
|
|
161
183
|
} catch (error) {
|
|
162
|
-
|
|
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)}`);
|
|
163
186
|
return { ok: false };
|
|
164
187
|
}
|
|
165
188
|
}
|
|
166
189
|
const printProblems = () => {
|
|
167
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}`);
|
|
168
192
|
};
|
|
169
193
|
|
|
170
194
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -180,7 +204,10 @@ for (let i = 0; i < args.length; i++) {
|
|
|
180
204
|
else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
|
|
181
205
|
else if (arg === "--doctor") opts.doctor = true;
|
|
182
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.
|
|
183
208
|
else if (arg === "--admission") opts.admission = true;
|
|
209
|
+
else if (arg === "--inspect") opts.inspect = true;
|
|
210
|
+
else if (arg === "--strict") opts.strict = true;
|
|
184
211
|
else if (arg === "--resume") opts.resume = true;
|
|
185
212
|
else if (arg === "--resume-from") { opts.resume = true; opts.resumeFrom = args[++i]; }
|
|
186
213
|
else if (arg === "--tier") opts.tier = args[++i];
|
|
@@ -194,6 +221,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
194
221
|
}
|
|
195
222
|
|
|
196
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");
|
|
197
226
|
if (opts.upload) fail("combined build+upload was removed; run right-release build, then right-release upload --release <id> --tier patch|update");
|
|
198
227
|
if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
|
|
199
228
|
const MAC_RESUME_PHASES = ["prepackage", "sign", "package", "post-sign", "harden"];
|
|
@@ -272,14 +301,13 @@ try {
|
|
|
272
301
|
ownership: RIGHTKIT_OWNERSHIP,
|
|
273
302
|
releaseVersion: VERSION,
|
|
274
303
|
boundSnapshot,
|
|
275
|
-
// The doctor
|
|
276
|
-
|
|
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,
|
|
277
307
|
});
|
|
278
308
|
} catch (error) {
|
|
279
309
|
rightKitResolution = null;
|
|
280
|
-
reject("rightkit-registry", error
|
|
281
|
-
? `${config.app ?? path.basename(root)} ${error.recoverable ? "RECOVERABLE " : ""}${error.message}`
|
|
282
|
-
: `RightKit registry resolution threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
310
|
+
reject("rightkit-registry", describeRegistryError(error, config.app ?? path.basename(root)));
|
|
283
311
|
}
|
|
284
312
|
if (rightKitResolution) {
|
|
285
313
|
const { newer, tasks, warnings, notes } = rightKitResolution.freshness;
|
|
@@ -291,12 +319,18 @@ if (rightKitResolution) {
|
|
|
291
319
|
}
|
|
292
320
|
if (rightKitResolution) rightKitVerifiedNpm = rightKitResolution.verified.npm;
|
|
293
321
|
// The contracts below are independent of each other; the two that read the proven RightKit versions need the registry resolution.
|
|
294
|
-
// A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
});
|
|
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.
|
|
324
|
+
if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
|
|
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
|
+
}
|
|
300
334
|
await check("rightkit-cargo-contract", ["rightkit-registry"], () => {
|
|
301
335
|
validateRightKitCargoContract(root, mergeVerifiedVersions(RIGHTKIT_CARGO_ALLOWED, rightKitResolution.verified.cargo), config.app ?? path.basename(root));
|
|
302
336
|
});
|
|
@@ -306,7 +340,7 @@ await check("cargo-profile-contract", [], () => assertCargoProfileContract(root,
|
|
|
306
340
|
await check("qa-adoption", [], () => assertQaAdoption({ appRoot: root, app: config.app }));
|
|
307
341
|
let target = config.targets?.[opts.platform];
|
|
308
342
|
// Declarative winPackage / macPackage (#345) expand to the same hook shape every gate below already checks.
|
|
309
|
-
// When it fails, `target` is not usable: every check that reads it is reported as
|
|
343
|
+
// When it fails, `target` is not usable: every check that reads it is reported as not run because `target` failed.
|
|
310
344
|
await check("target", [], () => {
|
|
311
345
|
let expanded;
|
|
312
346
|
try { expanded = expandPackageRecipeTarget(target, { platform: opts.platform, appRoot: root, config }); } catch (error) { throw new Error(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
|
|
@@ -324,7 +358,7 @@ let androidTarget; // validated Android AAB target (android only; see android-re
|
|
|
324
358
|
let iosSpec; // validated iOS iosPackage spec (ios only; package-recipe-targets.mjs expands targets.ios, ios-package-recipe.mjs runs it)
|
|
325
359
|
const app = config.app ?? "app";
|
|
326
360
|
// 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
|
|
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.
|
|
328
362
|
if (!rejectBlocked("target-checks", ["target"], "the target checks (signed, mediaSidecar, externalBin, publishBlocked and the platform signing and package contract)")) {
|
|
329
363
|
if (target.signed !== true) reject("target-signed", `${app} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
|
|
330
364
|
if (target.mediaSidecar !== undefined) {
|
|
@@ -348,7 +382,7 @@ if (!rejectBlocked("target-checks", ["target"], "the target checks (signed, medi
|
|
|
348
382
|
await check("win-msix", [], () => assertValidMsixTarget(target, app));
|
|
349
383
|
} else {
|
|
350
384
|
// The NSIS and portable rules below depend on which contract the target declares.
|
|
351
|
-
|
|
385
|
+
rejectBlocked("win-contract-rules", ["win-signing-contract"], "the win NSIS and portable rules (sign.files, distinct raw EXE and installer files)");
|
|
352
386
|
if (!target.prePackage?.cmd) reject("win-prepackage", `${app} win must declare prePackage for the raw EXE build`);
|
|
353
387
|
const rawFiles = target.sign?.prePackageFiles;
|
|
354
388
|
if (!rawFiles?.length) reject("win-sign-prepackage-files", `${app} win must declare at least one sign.prePackageFiles raw EXE`);
|
|
@@ -469,6 +503,17 @@ if (opts.doctor) {
|
|
|
469
503
|
const dirtyInputs = dirtyBuildInputs(doctorInvocation.repoRoot, inputs.value.buildInputs, doctorInvocation.commit);
|
|
470
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")}`);
|
|
471
505
|
});
|
|
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;
|
|
516
|
+
}
|
|
472
517
|
console.log(`right-release ${VERSION}`);
|
|
473
518
|
console.log(`config: ${configPath}`);
|
|
474
519
|
console.log(`app: ${config.app}`);
|
|
@@ -534,7 +579,9 @@ if (opts.doctor) {
|
|
|
534
579
|
if (gateFindings.length) {
|
|
535
580
|
console.log(gateFindings.map((finding) => ` [FAIL] gate:${finding.name}: ${finding.detail}`).join("\n"));
|
|
536
581
|
}
|
|
537
|
-
|
|
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);
|
|
538
585
|
if (total > 0) {
|
|
539
586
|
console.error(`right-release: preflight found ${total} blocking problem(s); the build would fail on these`);
|
|
540
587
|
process.exit(1);
|
|
@@ -876,10 +923,7 @@ function assertSharedSccachePrerequisite() {
|
|
|
876
923
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
877
924
|
const version = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
|
|
878
925
|
const valid = result.status === 0 && version && isAtLeastVersion(version.slice(1).map(Number), [0, 15, 0]);
|
|
879
|
-
if (!valid)
|
|
880
|
-
const found = version ? ` (found ${version[0]})` : "";
|
|
881
|
-
fail(`shared cache mode requires sccache >= 0.15.0${found}. Install with: cargo install sccache --locked; then verify: sccache --version`);
|
|
882
|
-
}
|
|
926
|
+
if (!valid) fail(sccacheRefusal(version?.[0]));
|
|
883
927
|
return version[0];
|
|
884
928
|
}
|
|
885
929
|
|
|
@@ -1135,38 +1179,49 @@ function commandTimeoutMs(command) {
|
|
|
1135
1179
|
: undefined;
|
|
1136
1180
|
}
|
|
1137
1181
|
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
1141
|
-
const scripts = pkg.scripts ?? {};
|
|
1142
|
-
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 = []) {
|
|
1143
1184
|
const workflowDir = path.join(root, ".github", "workflows");
|
|
1144
|
-
if (existsSync(workflowDir))
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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) {
|
|
1150
1191
|
if (pkg.packageManager !== RIGHTKIT_VERSIONS.packageManager) {
|
|
1151
|
-
|
|
1192
|
+
throw new ReleaseRefusal(`${appName} packageManager must be ${RIGHTKIT_VERSIONS.packageManager}, got ${pkg.packageManager ?? "<missing>"}`, "package-manager");
|
|
1152
1193
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
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");
|
|
1155
1200
|
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Every @rightkit/* dependency, each violation its own line of the finding.
|
|
1204
|
+
function validateRightKitPinSpecs(pkg, appName) {
|
|
1205
|
+
const found = [];
|
|
1156
1206
|
const rightKitDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
1157
1207
|
for (const [name, specifier] of Object.entries(rightKitDeps).filter(([name]) => name.startsWith("@rightkit/"))) {
|
|
1158
1208
|
if (FORBIDDEN_RIGHTKIT_SPEC.test(String(specifier))) {
|
|
1159
|
-
|
|
1209
|
+
found.push(`${appName} ${name} must come from the published npm package, not ${specifier}`);
|
|
1210
|
+
continue;
|
|
1160
1211
|
}
|
|
1161
1212
|
const allowed = RIGHTKIT_ALLOWED.get(name);
|
|
1162
1213
|
if (allowed && !rightKitVerifiedNpm.get(name)?.has(specifier) && !rightKitVersionAllowed(allowed, specifier)) {
|
|
1163
|
-
|
|
1214
|
+
found.push(`${appName} ${name} must be one of ${[...allowed].join(", ")}, got ${specifier}`);
|
|
1164
1215
|
}
|
|
1165
1216
|
}
|
|
1217
|
+
if (found.length) throw new ReleaseRefusal(found.join("\n"), "rightkit-pin-specs", found);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function validateRightKitReleaseSupported(pkg, appName) {
|
|
1166
1221
|
const releaseAllowed = RIGHTKIT_ALLOWED.get("@rightkit/release");
|
|
1167
1222
|
const releaseSpecifier = pkg.devDependencies?.["@rightkit/release"];
|
|
1168
1223
|
if (!releaseAllowed || !(rightKitVerifiedNpm.get("@rightkit/release")?.has(releaseSpecifier) || rightKitVersionAllowed(releaseAllowed, releaseSpecifier))) {
|
|
1169
|
-
|
|
1224
|
+
throw new ReleaseRefusal(`${appName} must depend on a supported @rightkit/release version in devDependencies`, "rightkit-release-supported");
|
|
1170
1225
|
}
|
|
1171
1226
|
}
|
|
1172
1227
|
|
|
@@ -1345,21 +1400,21 @@ async function signExistingRenderRefusal(root, manifest, source) {
|
|
|
1345
1400
|
async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles) {
|
|
1346
1401
|
const validator = (await loadRightKitCiModule(root, "workflows.mjs", "validateWorkflows"))?.validateWorkflows;
|
|
1347
1402
|
const renderLanes = await loadRightKitCiRenderer(root);
|
|
1348
|
-
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");
|
|
1349
1404
|
const errors = [];
|
|
1350
1405
|
validator(manifest, errors);
|
|
1351
|
-
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");
|
|
1352
1407
|
let rendered;
|
|
1353
1408
|
try {
|
|
1354
1409
|
rendered = renderLanes(manifest, root);
|
|
1355
1410
|
} catch (error) {
|
|
1356
|
-
|
|
1411
|
+
throw new ReleaseRefusal(`${appName} right-git-ci-only policy cannot render the .rightgit.json workflows block: ${error.message}`, "hosted-workflows");
|
|
1357
1412
|
}
|
|
1358
1413
|
for (const file of declaredFiles) {
|
|
1359
1414
|
const expected = rendered.find((entry) => entry.filename === file)?.content;
|
|
1360
1415
|
const actual = await readFile(path.join(root, ".github", "workflows", file), "utf8");
|
|
1361
1416
|
if (typeof expected !== "string" || actual.replace(/\r\n?/g, "\n") !== expected.replace(/\r\n?/g, "\n")) {
|
|
1362
|
-
|
|
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");
|
|
1363
1418
|
}
|
|
1364
1419
|
}
|
|
1365
1420
|
}
|
|
@@ -1368,7 +1423,7 @@ async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFi
|
|
|
1368
1423
|
// `contents: read`, no other permissions key exists, no secrets reference, every `uses:` is commit-pinned, and every
|
|
1369
1424
|
// checkout step sets persist-credentials: false. Steps are split on step-list indentation, not matched as free text.
|
|
1370
1425
|
function declaredWorkflowPolicyRefusal(appName, workflow, source) {
|
|
1371
|
-
const refuse = (why) =>
|
|
1426
|
+
const refuse = (why) => { throw new ReleaseRefusal(`${appName} right-git managed ${workflow} violates the declared-workflow policy: ${why}`, "hosted-workflows"); };
|
|
1372
1427
|
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
1373
1428
|
const permissionsAt = lines.flatMap((line, i) => (/^\s*permissions\s*:/.test(line) ? [i] : []));
|
|
1374
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`");
|
|
@@ -1398,18 +1453,18 @@ async function validateHostedWorkflows(root, appName, workflows, policy, artifac
|
|
|
1398
1453
|
const texts = new Map();
|
|
1399
1454
|
for (const file of workflows) texts.set(file, await readFile(path.join(root, ".github", "workflows", file), "utf8"));
|
|
1400
1455
|
const refusal = declaredUnrelatedRefusal({ declaration: policy, workflows, read: (file) => texts.get(file), artifacts });
|
|
1401
|
-
if (refusal)
|
|
1456
|
+
if (refusal) throw new ReleaseRefusal(`${appName} ${DECLARED_UNRELATED_POLICY} hosted workflows refused: ${refusal}`, "hosted-workflows");
|
|
1402
1457
|
return;
|
|
1403
1458
|
}
|
|
1404
1459
|
if (policy !== "right-git-ci-only") {
|
|
1405
|
-
|
|
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");
|
|
1406
1461
|
}
|
|
1407
1462
|
const manifestPath = path.join(root, ".rightgit.json");
|
|
1408
1463
|
let manifest;
|
|
1409
1464
|
try {
|
|
1410
1465
|
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
1411
1466
|
} catch {
|
|
1412
|
-
|
|
1467
|
+
throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires a valid .rightgit.json`, "hosted-workflows");
|
|
1413
1468
|
}
|
|
1414
1469
|
const lanes = manifest?.lanes;
|
|
1415
1470
|
// Mirrors the `lanes` enum in @rightkit/ci manifest.schema.json.
|
|
@@ -1421,29 +1476,29 @@ async function validateHostedWorkflows(root, appName, workflows, policy, artifac
|
|
|
1421
1476
|
|| !(lanes.includes("ci") || lanes.includes("swift-check"))
|
|
1422
1477
|
|| new Set(lanes).size !== lanes.length
|
|
1423
1478
|
|| lanes.some((lane) => !safeLanes.has(lane))) {
|
|
1424
|
-
|
|
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");
|
|
1425
1480
|
}
|
|
1426
1481
|
// Workflows declared under the manifest's `workflows` block (@rightkit/ci workflows.mjs: explicit `file`,
|
|
1427
1482
|
// else `<name>.yml`) are managed files too, in addition to the lane files.
|
|
1428
1483
|
const declaredWorkflows = manifest?.workflows;
|
|
1429
1484
|
if (declaredWorkflows !== undefined && (declaredWorkflows === null || typeof declaredWorkflows !== "object" || Array.isArray(declaredWorkflows))) {
|
|
1430
|
-
|
|
1485
|
+
throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires .rightgit.json workflows to be an object`, "hosted-workflows");
|
|
1431
1486
|
}
|
|
1432
1487
|
const declaredFiles = Object.entries(declaredWorkflows ?? {}).map(([name, config]) => {
|
|
1433
|
-
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");
|
|
1434
1489
|
const file = typeof config?.file === "string" ? config.file : `${name}.yml`;
|
|
1435
|
-
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");
|
|
1436
1491
|
return file;
|
|
1437
1492
|
});
|
|
1438
1493
|
const expectedWorkflows = [...new Set([...lanes.map((lane) => `${lane}.yml`), ...declaredFiles])].sort();
|
|
1439
1494
|
if (JSON.stringify(workflows) !== JSON.stringify(expectedWorkflows)) {
|
|
1440
|
-
|
|
1495
|
+
throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires workflows declared by .rightgit.json; expected ${expectedWorkflows.join(", ")}, got: ${workflows.join(", ")}`, "hosted-workflows");
|
|
1441
1496
|
}
|
|
1442
1497
|
if (declaredFiles.length > 0) await validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles);
|
|
1443
1498
|
for (const workflow of expectedWorkflows) {
|
|
1444
1499
|
const source = await readFile(path.join(root, ".github", "workflows", workflow), "utf8");
|
|
1445
1500
|
if (!source.startsWith("# Managed by right-git")) {
|
|
1446
|
-
|
|
1501
|
+
throw new ReleaseRefusal(`${appName} ${workflow} is not marked as right-git managed`, "hosted-workflows");
|
|
1447
1502
|
}
|
|
1448
1503
|
if (declaredFiles.includes(workflow)) declaredWorkflowPolicyRefusal(appName, workflow, source);
|
|
1449
1504
|
const jobSpan = (jobName) => {
|
|
@@ -1530,7 +1585,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy, artifac
|
|
|
1530
1585
|
const reason = manifest?.signExisting && typeof manifest.signExisting === "object"
|
|
1531
1586
|
? (signExistingRefusal(source) ?? await signExistingRenderRefusal(root, manifest, source))
|
|
1532
1587
|
: "the manifest declares no signExisting";
|
|
1533
|
-
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");
|
|
1534
1589
|
protectedSignExisting = true;
|
|
1535
1590
|
}
|
|
1536
1591
|
// @rightkit/ci `swift.privatePackages` credential: allowed only in the workflow/job pairs and on the
|
|
@@ -1573,7 +1628,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy, artifac
|
|
|
1573
1628
|
|| refusedSecrets.length > 0) {
|
|
1574
1629
|
const reasons = [...new Set(refusedSecrets)];
|
|
1575
1630
|
const detail = reasons.length > 10 ? [...reasons.slice(0, 10), `${reasons.length - 10} more`] : reasons;
|
|
1576
|
-
|
|
1631
|
+
throw new ReleaseRefusal(`${appName} right-git managed ${workflow} requests release-capable permissions or secrets${detail.length > 0 ? `: ${detail.join("; ")}` : ""}`, "hosted-workflows");
|
|
1577
1632
|
}
|
|
1578
1633
|
}
|
|
1579
1634
|
}
|
package/rightkit-versions.json
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"stagedNpm": {
|
|
16
16
|
"@rightkit/app-update": "0.4.2",
|
|
17
17
|
"@rightkit/ax": "0.3.2",
|
|
18
|
-
"@rightkit/ci": "0.3.
|
|
18
|
+
"@rightkit/ci": "0.3.1",
|
|
19
19
|
"@rightkit/doc-render": "0.2.1",
|
|
20
20
|
"@rightkit/hooks": "0.1.2",
|
|
21
21
|
"@rightkit/i18n": "0.2.0",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@rightkit/qa": "0.6.1",
|
|
33
33
|
"@rightkit/qa-chrome": "0.1.11",
|
|
34
34
|
"@rightkit/react-shell": "0.7.2",
|
|
35
|
-
"@rightkit/release": "0.6.
|
|
35
|
+
"@rightkit/release": "0.6.5",
|
|
36
36
|
"@rightkit/settings-ui": "0.7.0",
|
|
37
37
|
"@rightkit/tauri": "0.3.0",
|
|
38
38
|
"@rightkit/tauri-shell": "0.1.3",
|
|
@@ -230,7 +230,8 @@
|
|
|
230
230
|
"0.6.0",
|
|
231
231
|
"0.6.1",
|
|
232
232
|
"0.6.2",
|
|
233
|
-
"0.6.3"
|
|
233
|
+
"0.6.3",
|
|
234
|
+
"0.6.4"
|
|
234
235
|
],
|
|
235
236
|
"@rightkit/qa": [
|
|
236
237
|
"0.1.0",
|
|
@@ -391,7 +392,8 @@
|
|
|
391
392
|
"0.2.43",
|
|
392
393
|
"0.2.44",
|
|
393
394
|
"0.2.45",
|
|
394
|
-
"0.2.49"
|
|
395
|
+
"0.2.49",
|
|
396
|
+
"0.3.0"
|
|
395
397
|
],
|
|
396
398
|
"@rightkit/react-shell": [
|
|
397
399
|
"0.6.4",
|
|
@@ -516,7 +518,7 @@
|
|
|
516
518
|
"stagedSwift": {
|
|
517
519
|
"rightkit-swift": {
|
|
518
520
|
"url": "https://github.com/Orthic-Labs/rightkit",
|
|
519
|
-
"version": "0.
|
|
521
|
+
"version": "0.8.1"
|
|
520
522
|
}
|
|
521
523
|
},
|
|
522
524
|
"registry": {
|
|
@@ -531,7 +533,7 @@
|
|
|
531
533
|
"rightkit-browser": "0.3.6",
|
|
532
534
|
"rightkit-cache": "0.2.3",
|
|
533
535
|
"rightkit-channel-memory": "0.2.1",
|
|
534
|
-
"rightkit-child-process": "0.5.
|
|
536
|
+
"rightkit-child-process": "0.5.6",
|
|
535
537
|
"rightkit-compose": "0.2.0",
|
|
536
538
|
"rightkit-contracts": "0.1.7",
|
|
537
539
|
"rightkit-control": "0.3.0",
|
|
@@ -573,7 +575,7 @@
|
|
|
573
575
|
"rightkit-platform-rules": "0.1.0",
|
|
574
576
|
"rightkit-playback": "0.3.0",
|
|
575
577
|
"rightkit-process": "0.4.0",
|
|
576
|
-
"rightkit-qa": "0.
|
|
578
|
+
"rightkit-qa": "0.9.0",
|
|
577
579
|
"rightkit-qa-driver": "0.7.0",
|
|
578
580
|
"rightkit-qts": "0.2.6",
|
|
579
581
|
"rightkit-qts-ggml": "0.1.2",
|
|
@@ -626,7 +628,7 @@
|
|
|
626
628
|
"@rightkit/qa": "0.6.1",
|
|
627
629
|
"@rightkit/qa-chrome": "0.1.11",
|
|
628
630
|
"@rightkit/react-shell": "0.7.2",
|
|
629
|
-
"@rightkit/release": "0.6.
|
|
631
|
+
"@rightkit/release": "0.6.4",
|
|
630
632
|
"@rightkit/settings-ui": "0.7.0",
|
|
631
633
|
"@rightkit/shell": "0.1.1",
|
|
632
634
|
"@rightkit/tauri": "0.3.0",
|