@rightkit/release 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -7
- package/adhoc-entitlements.mjs +8 -0
- package/adoption-evidence.mjs +650 -0
- package/advisory-exceptions.mjs +371 -0
- package/apple-profile-create.mjs +113 -10
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1079 -0
- package/binary-license-notices.mjs +161 -0
- package/cargo-license-notices.mjs +111 -0
- package/cli/right-release.mjs +39 -0
- package/deterministic-scans.mjs +966 -0
- package/development-signing.mjs +2 -1
- package/host-upload-refusal.mjs +90 -0
- package/local-publish.mjs +142 -19
- package/local-qualify.mjs +728 -0
- package/local-runtime.mjs +319 -0
- package/lock-graph.mjs +51 -0
- package/managed-cargo.mjs +4 -1
- package/ownership-approval.mjs +23 -6
- package/ownership-dependency-allowlist.json +2 -0
- package/ownership-mandatory-set.json +158 -68
- package/ownership-mandatory-set.mjs +82 -6
- package/package.json +6 -2
- package/pipeline-admission.mjs +375 -0
- package/pipeline-config.mjs +100 -0
- package/pipeline-generators.json +4 -0
- package/pipeline-generators.mjs +137 -0
- package/pipeline-receipts.mjs +302 -0
- package/pipeline-scan.mjs +285 -0
- package/pipeline-tools.mjs +171 -0
- package/production-mode.mjs +42 -0
- package/publication-gate.mjs +94 -32
- package/publish-cargo.mjs +24 -7
- package/publish-swift.mjs +12 -3
- package/qa-control-scan.mjs +4 -0
- package/release-config.mjs +330 -0
- package/release-switch-scan.mjs +44 -5
- package/release.mjs +14 -4
- package/rightkit-ownership.json +57 -0
- package/rightkit-sync.mjs +8 -0
- package/rightkit-versions.json +191 -131
- package/runtime-budget-classes.json +8 -0
- package/runtime-budget-crates.mjs +14 -0
- package/runtime-budgets.mjs +128 -31
- package/scan-digest.mjs +107 -0
- package/sign-qa-bundle.mjs +210 -0
- package/stage-sidecar.mjs +99 -15
- package/swap-helper-bundle.mjs +7 -3
- package/workflow-yaml.mjs +177 -0
- package/bin/darwin/rightkit-swap.sign.json +0 -6
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"ceilings": null,
|
|
22
22
|
"requiredScale": null
|
|
23
23
|
},
|
|
24
|
+
"rightkit-crash": {
|
|
25
|
+
"ceilings": null,
|
|
26
|
+
"requiredScale": null
|
|
27
|
+
},
|
|
24
28
|
"rightkit-embed": {
|
|
25
29
|
"ceilings": null,
|
|
26
30
|
"requiredScale": null
|
|
@@ -41,6 +45,10 @@
|
|
|
41
45
|
"ceilings": null,
|
|
42
46
|
"requiredScale": null
|
|
43
47
|
},
|
|
48
|
+
"rightkit-overlay-win": {
|
|
49
|
+
"ceilings": null,
|
|
50
|
+
"requiredScale": null
|
|
51
|
+
},
|
|
44
52
|
"rightkit-record": {
|
|
45
53
|
"ceilings": null,
|
|
46
54
|
"requiredScale": null
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The continuous owners that must carry a runtime budget (docs/RUNTIME-BUDGETS.md). Kept free of
|
|
2
|
+
// dependencies so the secretless publish plan job can check the runtime proof table against it.
|
|
3
|
+
export const RUNTIME_BUDGET_CRATES = Object.freeze([
|
|
4
|
+
"rightkit-fsindex",
|
|
5
|
+
"rightkit-file-watch",
|
|
6
|
+
"rightkit-job-queue",
|
|
7
|
+
"rightkit-daemon",
|
|
8
|
+
"rightkit-cache",
|
|
9
|
+
"rightkit-sync",
|
|
10
|
+
"rightkit-logs",
|
|
11
|
+
"rightkit-tauri-shell",
|
|
12
|
+
"rightkit-qa",
|
|
13
|
+
"rightkit-send",
|
|
14
|
+
]);
|
package/runtime-budgets.mjs
CHANGED
|
@@ -59,6 +59,7 @@ import path from "node:path";
|
|
|
59
59
|
import { fileURLToPath } from "node:url";
|
|
60
60
|
import { spawnSync } from "./background-process.mjs";
|
|
61
61
|
import { parse } from "smol-toml";
|
|
62
|
+
import { RUNTIME_BUDGET_CRATES } from "./runtime-budget-crates.mjs";
|
|
62
63
|
import { singleHostOfPolicy } from "./publication-gate.mjs";
|
|
63
64
|
import { findWorkspaceRoot, lockClosure, pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
|
|
64
65
|
|
|
@@ -68,18 +69,7 @@ export const RUNTIME_BUDGET_FILE = "runtime-budget.json";
|
|
|
68
69
|
export const RUNTIME_BUDGET_EVIDENCE_DIR = "runtime-budget.evidence";
|
|
69
70
|
export const RUNTIME_BUDGET_SCHEMA = 1;
|
|
70
71
|
|
|
71
|
-
export
|
|
72
|
-
"rightkit-fsindex",
|
|
73
|
-
"rightkit-file-watch",
|
|
74
|
-
"rightkit-job-queue",
|
|
75
|
-
"rightkit-daemon",
|
|
76
|
-
"rightkit-cache",
|
|
77
|
-
"rightkit-sync",
|
|
78
|
-
"rightkit-logs",
|
|
79
|
-
"rightkit-tauri-shell",
|
|
80
|
-
"rightkit-qa",
|
|
81
|
-
"rightkit-send",
|
|
82
|
-
]);
|
|
72
|
+
export { RUNTIME_BUDGET_CRATES };
|
|
83
73
|
|
|
84
74
|
export const BUDGET_FIGURES = Object.freeze(["residentMemoryBytes", "idleCpuPercent", "wakeupsPerSecond", "diskOpsPerSecond"]);
|
|
85
75
|
const FIGURE_LABEL = Object.freeze({
|
|
@@ -260,17 +250,86 @@ const posix = (p) => p.split(path.sep).join("/");
|
|
|
260
250
|
/**
|
|
261
251
|
* The git pathspecs (relative to the workspace root) whose change makes a measurement stale: the
|
|
262
252
|
* crate's src, benches, Cargo.toml (features) and build.rs, plus src, Cargo.toml and build.rs of every
|
|
263
|
-
* workspace crate it reaches through `path` dependencies (transitively). Returns { root, specs }.
|
|
253
|
+
* workspace crate it reaches through `path` dependencies (transitively). Returns { root, specs, problems }.
|
|
254
|
+
* Callers must refuse incomplete metadata rather than using a partial dependency scope.
|
|
264
255
|
*/
|
|
265
256
|
export function measuredPathspecs(crateDir, deps = {}) {
|
|
266
|
-
const
|
|
257
|
+
const { exists = existsSync, readFile = readFileSync } = deps;
|
|
258
|
+
const problems = [];
|
|
259
|
+
// Workspace discovery deliberately tolerates bad ancestor manifests elsewhere. Freshness cannot:
|
|
260
|
+
// a swallowed read/parse failure could hide inherited dependencies and shrink the measured graph.
|
|
261
|
+
const checkedRead = (file, encoding) => {
|
|
262
|
+
try {
|
|
263
|
+
const contents = readFile(file, encoding);
|
|
264
|
+
if (path.basename(file) === "Cargo.toml") parse(contents);
|
|
265
|
+
return contents;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
problems.push(`cannot read dependency metadata ${file}: ${error.message}`);
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const checked = { exists, readFile: checkedRead };
|
|
272
|
+
let root = path.resolve(crateDir);
|
|
267
273
|
const specs = [];
|
|
268
274
|
const add = (dir, names) => { for (const name of names) specs.push(posix(path.relative(root, path.join(dir, name)))); };
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
275
|
+
try {
|
|
276
|
+
root = findWorkspaceRoot(crateDir, checked) ?? root;
|
|
277
|
+
add(path.resolve(crateDir), CRATE_PATHS);
|
|
278
|
+
const dependencies = pathDependencyDirs(crateDir, checked);
|
|
279
|
+
const workspace = exists(path.join(root, "Cargo.toml")) ? parse(checkedRead(path.join(root, "Cargo.toml"), "utf8")).workspace : undefined;
|
|
280
|
+
for (const dir of [path.resolve(crateDir), ...dependencies]) {
|
|
281
|
+
const manifestPath = path.join(dir, "Cargo.toml");
|
|
282
|
+
if (!exists(manifestPath)) {
|
|
283
|
+
// A manifest-free standalone path has no Cargo graph. A declared path dependency must exist.
|
|
284
|
+
if (dir !== path.resolve(crateDir)) problems.push(`dependency manifest is missing: ${manifestPath}`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const manifest = parse(checkedRead(manifestPath, "utf8"));
|
|
288
|
+
if (!nonEmpty(manifest.package?.name)) problems.push(`dependency manifest has no package.name: ${manifestPath}`);
|
|
289
|
+
const holders = [manifest, ...Object.values(manifest.target ?? {})];
|
|
290
|
+
for (const holder of holders) {
|
|
291
|
+
for (const key of ["dependencies", "build-dependencies", ...(dir === path.resolve(crateDir) ? ["dev-dependencies"] : [])]) {
|
|
292
|
+
for (const [name, spec] of Object.entries(holder?.[key] ?? {})) {
|
|
293
|
+
if (spec && typeof spec === "object" && spec.workspace === true && workspace?.dependencies?.[name] === undefined) {
|
|
294
|
+
problems.push(`workspace dependency ${name} cannot be resolved for ${manifestPath}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const dir of dependencies) add(dir, DEPENDENCY_PATHS);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (!problems.length) problems.push(`cannot resolve measured dependency paths for ${crateDir}: ${error.message}`);
|
|
303
|
+
}
|
|
304
|
+
return { root, specs: [...new Set(specs)], problems: [...new Set(problems)] };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// lockClosure compares reachable entries, but its permissive parser can return an empty/partial graph.
|
|
308
|
+
// Require the root and every reachable edge before allowing equality to establish freshness.
|
|
309
|
+
function completeLockClosure(contents, name) {
|
|
310
|
+
const packages = parse(contents).package;
|
|
311
|
+
if (!Array.isArray(packages)) throw new Error("missing [[package]] entries");
|
|
312
|
+
const roots = packages.filter((entry) => entry.name === name);
|
|
313
|
+
if (!roots.length) throw new Error(`missing package ${name}`);
|
|
314
|
+
const seen = new Set();
|
|
315
|
+
const pending = [...roots];
|
|
316
|
+
while (pending.length) {
|
|
317
|
+
const entry = pending.pop();
|
|
318
|
+
if (seen.has(entry)) continue;
|
|
319
|
+
seen.add(entry);
|
|
320
|
+
if (!nonEmpty(entry.name) || !nonEmpty(entry.version)) throw new Error(`incomplete package identity for ${entry.name ?? name}`);
|
|
321
|
+
if (entry.dependencies !== undefined && !Array.isArray(entry.dependencies)) throw new Error(`invalid dependencies for ${entry.name}`);
|
|
322
|
+
for (const edge of entry.dependencies ?? []) {
|
|
323
|
+
if (!nonEmpty(edge)) throw new Error(`invalid dependency edge for ${entry.name}`);
|
|
324
|
+
const match = /^(\S+)(?: (\S+))?(?: \(([^)]+)\))?$/.exec(edge);
|
|
325
|
+
if (!match) throw new Error(`invalid dependency ${edge} of ${entry.name}`);
|
|
326
|
+
const candidates = packages.filter((candidate) => candidate.name === match[1]
|
|
327
|
+
&& (!match[2] || candidate.version === match[2]) && (!match[3] || candidate.source === match[3]));
|
|
328
|
+
if (!candidates.length) throw new Error(`unresolved dependency ${edge} of ${entry.name}`);
|
|
329
|
+
pending.push(...candidates);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return lockClosure(contents, name);
|
|
274
333
|
}
|
|
275
334
|
|
|
276
335
|
/**
|
|
@@ -289,7 +348,8 @@ export function measurementStaleness(commit, crateDir, git = defaultGit, deps =
|
|
|
289
348
|
const known = git(["cat-file", "-t", commit], crateDir);
|
|
290
349
|
if (known.status !== 0 || known.stdout !== "commit") return `commit ${commit.slice(0, 12)} is not a commit in this repository (shallow clone?)`;
|
|
291
350
|
if (git(["merge-base", "--is-ancestor", commit, "HEAD"], crateDir).status !== 0) return `commit ${commit.slice(0, 12)} is not an ancestor of HEAD`;
|
|
292
|
-
const { root, specs } = measuredPathspecs(crateDir, { exists, readFile });
|
|
351
|
+
const { root, specs, problems } = measuredPathspecs(crateDir, { exists, readFile });
|
|
352
|
+
if (problems.length) return `cannot resolve measured dependency graph: ${problems.join("; ")}`;
|
|
293
353
|
// `git diff <commit>` (no second revision) compares the working tree to the commit.
|
|
294
354
|
const changed = git(["diff", "--name-only", commit, "--", ...specs], root);
|
|
295
355
|
if (changed.status !== 0) return "cannot diff the crate against the measured commit";
|
|
@@ -298,17 +358,29 @@ export function measurementStaleness(commit, crateDir, git = defaultGit, deps =
|
|
|
298
358
|
if (untracked.status !== 0) return "cannot list untracked crate files";
|
|
299
359
|
if (untracked.stdout !== "") return `untracked files since the measurement: ${untracked.stdout.split("\n").slice(0, 5).join(", ")}`;
|
|
300
360
|
let name = null;
|
|
301
|
-
try {
|
|
361
|
+
try {
|
|
362
|
+
if (exists(path.join(crateDir, "Cargo.toml"))) {
|
|
363
|
+
name = readCargoPackage(crateDir, { exists, readFile }).name;
|
|
364
|
+
if (!nonEmpty(name)) return `cannot resolve Cargo.lock: ${path.join(crateDir, "Cargo.toml")} has no package.name`;
|
|
365
|
+
}
|
|
366
|
+
} catch (error) { return `cannot resolve Cargo.lock package identity: ${error.message}`; }
|
|
302
367
|
if (name) {
|
|
303
368
|
const lockPath = path.join(root, "Cargo.lock");
|
|
304
|
-
|
|
369
|
+
let now;
|
|
370
|
+
try { now = exists(lockPath) ? readFile(lockPath, "utf8") : null; }
|
|
371
|
+
catch (error) { return `cannot read Cargo.lock ${lockPath}: ${error.message}`; }
|
|
305
372
|
const then = git(["show", `${commit}:./Cargo.lock`], root);
|
|
306
373
|
const had = then.status === 0;
|
|
307
374
|
if (now === null && had) return "Cargo.lock was removed since the measurement";
|
|
308
375
|
if (now !== null && !had) return "Cargo.lock did not exist at the measured commit";
|
|
376
|
+
if (now === null && !had) {
|
|
377
|
+
// A failed show alone cannot distinguish an absent lock from an unreadable Git object.
|
|
378
|
+
const listed = git(["ls-tree", commit, "--", "Cargo.lock"], root);
|
|
379
|
+
if (listed.status !== 0 || listed.stdout !== "") return "cannot establish that Cargo.lock was absent at the measured commit";
|
|
380
|
+
}
|
|
309
381
|
if (now !== null) {
|
|
310
382
|
try {
|
|
311
|
-
if (JSON.stringify(
|
|
383
|
+
if (JSON.stringify(completeLockClosure(now, name)) !== JSON.stringify(completeLockClosure(then.stdout, name))) {
|
|
312
384
|
return `Cargo.lock changed in the dependency graph of ${name} since commit ${commit.slice(0, 12)}`;
|
|
313
385
|
}
|
|
314
386
|
} catch (error) { return `cannot compare Cargo.lock: ${error.message}`; }
|
|
@@ -500,7 +572,14 @@ export function statusApiDeclared(api, source) {
|
|
|
500
572
|
* `evidence` is the release gate evidence for it (the host-policy authority). `requireInstalled`
|
|
501
573
|
* (adoption) additionally needs a fresh in-ceiling installed-app measurement, optionally for `app`.
|
|
502
574
|
*/
|
|
503
|
-
export function evaluateRuntimeBudget(
|
|
575
|
+
export function evaluateRuntimeBudget(options, deps = {}) {
|
|
576
|
+
return evaluateRuntimeBudgetOwned(options, deps);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Private owner mode: no caller-supplied boolean can weaken publication or adoption.
|
|
580
|
+
const STRUCTURE_ONLY = Symbol("runtime-budget-structure-only");
|
|
581
|
+
function evaluateRuntimeBudgetOwned({ crate, crateDir, version, evidence, requireInstalled = false, app, heldResource = false }, { readFile = readFileSync, exists = existsSync, git = defaultGit } = {}, mode) {
|
|
582
|
+
const structural = mode === STRUCTURE_ONLY;
|
|
504
583
|
const file = path.join(crateDir, RUNTIME_BUDGET_FILE);
|
|
505
584
|
if (!exists(file)) return { ok: false, problems: [`${RUNTIME_BUDGET_FILE} is missing (docs/RUNTIME-BUDGETS.md)`] };
|
|
506
585
|
let budget;
|
|
@@ -574,7 +653,7 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
|
|
|
574
653
|
|
|
575
654
|
// Measurements.
|
|
576
655
|
const records = Array.isArray(budget.measurements) ? budget.measurements : [];
|
|
577
|
-
if (records.length === 0) problems.push("no measurements recorded (a declared ceiling is not evidence)");
|
|
656
|
+
if (!structural && records.length === 0) problems.push("no measurements recorded (a declared ceiling is not evidence)");
|
|
578
657
|
const valid = [];
|
|
579
658
|
records.forEach((m, index) => {
|
|
580
659
|
const bad = validateMeasurement(m, index);
|
|
@@ -583,7 +662,7 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
|
|
|
583
662
|
if (evidenceProblems.length) problems.push(...evidenceProblems); else valid.push({ m, index });
|
|
584
663
|
});
|
|
585
664
|
const counted = valid.filter(({ m }) => m.crateVersion === version);
|
|
586
|
-
if (valid.length && nonEmpty(version) && counted.length === 0) {
|
|
665
|
+
if (!structural && valid.length && nonEmpty(version) && counted.length === 0) {
|
|
587
666
|
problems.push(`no measurement for version ${version} (recorded: ${[...new Set(valid.map(({ m }) => m.crateVersion))].join(", ")})`);
|
|
588
667
|
}
|
|
589
668
|
// Why a measurement fails one figure, or null. A delta-basis figure of a synthetic measurement is gated on
|
|
@@ -608,7 +687,7 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
|
|
|
608
687
|
}
|
|
609
688
|
return null;
|
|
610
689
|
};
|
|
611
|
-
for (const { m, index } of counted) {
|
|
690
|
+
for (const { m, index } of (structural ? valid : counted)) {
|
|
612
691
|
for (const key of BUDGET_FIGURES) {
|
|
613
692
|
const failure = figureFailure(m, key);
|
|
614
693
|
if (failure) problems.push(`measurement[${index}] on ${m.host.os}/${m.host.arch} ${failure}`);
|
|
@@ -616,6 +695,14 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
|
|
|
616
695
|
}
|
|
617
696
|
const withinCeilings = (m) => BUDGET_FIGURES.every((key) => ceilings[key] !== undefined && (usesDelta(m, key) ? deltaCeilings[key] !== undefined && figureFailure(m, key) === null : m.observed[key] === null ? figureUnavailableOn(m.host?.os, key) : m.observed[key] <= ceilings[key]));
|
|
618
697
|
const atScale = (m) => requiredScale !== null && m.dataScale.unit === requiredScale.unit && m.dataScale.count >= requiredScale.minCount;
|
|
698
|
+
if (structural) {
|
|
699
|
+
if (!Array.isArray(budget.measurements)) problems.push("measurements must be an array");
|
|
700
|
+
const scope = measuredPathspecs(crateDir, { exists, readFile });
|
|
701
|
+
problems.push(...scope.problems);
|
|
702
|
+
for (const key of deltaFigures) if (!nonEmpty(budget.deltaSources?.[key])) problems.push(`deltaSources.${key} must name its ceiling source`);
|
|
703
|
+
if (budget.deltaSources !== undefined && (!budget.deltaSources || typeof budget.deltaSources !== "object" || Array.isArray(budget.deltaSources) || Object.keys(budget.deltaSources).some((key) => !deltaFigures.includes(key)))) problems.push("deltaSources must name only declared delta figures");
|
|
704
|
+
for (const { m, index } of valid) if (!atScale(m)) problems.push(`measurement[${index}] is below required scale`);
|
|
705
|
+
}
|
|
619
706
|
const staleness = new Map();
|
|
620
707
|
const stale = (m) => {
|
|
621
708
|
if (!staleness.has(m.commit)) staleness.set(m.commit, measurementStaleness(m.commit, crateDir, git, { exists, readFile }));
|
|
@@ -624,7 +711,7 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
|
|
|
624
711
|
|
|
625
712
|
// Required hosts: one fresh, in-ceiling, at-scale measurement of this version on each.
|
|
626
713
|
// A held-resource crate defers measurement to adoption: no per-host publish measurement is required.
|
|
627
|
-
const required = heldResource ? [] : requiredBudgetHosts(budget, evidence);
|
|
714
|
+
const required = heldResource || structural ? [] : requiredBudgetHosts(budget, evidence);
|
|
628
715
|
for (const host of required) {
|
|
629
716
|
const onHost = counted.filter(({ m }) => m.host.os === host);
|
|
630
717
|
if (onHost.length === 0) { problems.push(`no ${HOSTS[host]} measurement for version ${version} (required hosts: ${required.map((h) => HOSTS[h]).join(" and ")}${required.length > 1 ? "; apps ship on both and the release gate requires both unless a darwin-only policy with a reason is recorded" : `; ${required[0]}-only policy: ${String((evidence ?? budget).hostPolicyReason).trim()}`})`); continue; }
|
|
@@ -728,7 +815,17 @@ export function heldResourceProblems({ crate, crateDir, meta }, { readFile = rea
|
|
|
728
815
|
* naming every matched pattern), "held-resource" (resources table, adoption-time proof) or always-on
|
|
729
816
|
* (runtime-budget.json, fully checked). `readFile`, `exists` and `git` are injectable.
|
|
730
817
|
*/
|
|
731
|
-
export function runtimeBudgetCheck(
|
|
818
|
+
export function runtimeBudgetCheck(options, deps = {}) {
|
|
819
|
+
return runtimeBudgetCheckOwned(options, deps);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Source structure only; grants no runtime qualification or semantic admission. */
|
|
823
|
+
export function runtimeBudgetStructureCheck(options, deps = {}) {
|
|
824
|
+
const result = runtimeBudgetCheckOwned({ ...options, requireInstalled: false }, deps, STRUCTURE_ONLY);
|
|
825
|
+
return { ...result, name: "runtime-budget-structure" };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function runtimeBudgetCheckOwned({ crate, crateDir, version, evidence, requireInstalled = false, app }, deps = {}, mode) {
|
|
732
829
|
const { readFile = readFileSync, exists = existsSync } = deps;
|
|
733
830
|
let pkg;
|
|
734
831
|
try { pkg = readCargoPackage(crateDir, { exists, readFile }); } catch (error) {
|
|
@@ -775,9 +872,9 @@ export function runtimeBudgetCheck({ crate, crateDir, version, evidence, require
|
|
|
775
872
|
return fail(crate, crateDir, [`undeclared: none of ${RUNTIME_BUDGET_FILE}, continuous = false with a continuous_reason, or continuous = "held-resource" with resources`],
|
|
776
873
|
`declare the crate (docs/RUNTIME-BUDGETS.md)`);
|
|
777
874
|
}
|
|
778
|
-
const result =
|
|
875
|
+
const result = evaluateRuntimeBudgetOwned({ crate, crateDir, version: version ?? pkg.version, evidence, requireInstalled, app }, deps, mode);
|
|
779
876
|
if (result.ok) {
|
|
780
|
-
return { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: `${crate} ${version ?? pkg.version} has measured runtime budgets on every required host` };
|
|
877
|
+
return { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: mode === STRUCTURE_ONLY ? `${crate} ${version ?? pkg.version} runtime budget structure is valid; qualification is separate` : `${crate} ${version ?? pkg.version} has measured runtime budgets on every required host` };
|
|
781
878
|
}
|
|
782
879
|
return fail(crate, crateDir, result.problems);
|
|
783
880
|
}
|
package/scan-digest.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Canonical JSON and the scan-set digest, shared by the scanner (deterministic-scans.mjs) and the credentialed
|
|
2
|
+
// receipt verifiers (scripts/ci/scan-receipt.mjs). No imports beyond node:crypto, by design.
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
6
|
+
|
|
7
|
+
/** Deterministic JSON: object keys sorted, no undefined, no non-finite numbers. */
|
|
8
|
+
export function canonicalJson(value) {
|
|
9
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
|
10
|
+
if (typeof value === "number") {
|
|
11
|
+
if (!Number.isFinite(value)) throw new TypeError("canonicalJson: non-finite number");
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
15
|
+
if (typeof value === "object") {
|
|
16
|
+
const keys = Object.keys(value).sort();
|
|
17
|
+
for (const key of keys) if (value[key] === undefined) throw new TypeError(`canonicalJson: undefined at ${key}`);
|
|
18
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
19
|
+
}
|
|
20
|
+
throw new TypeError(`canonicalJson: unsupported ${typeof value}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Digest of a scan set: the sorted (id, class, command) of every entry. Plan items and result rows agree. */
|
|
24
|
+
export function digestScanSet(entries) {
|
|
25
|
+
const rows = entries.map((e) => [String(e.id), String(e.class), String(e.command)]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
26
|
+
return sha256(canonicalJson(rows));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---- required scan ids ---------------------------------------------------------------------------------
|
|
30
|
+
// The one place the scan ids that belong to a crate are spelled, shared by planScans (deterministic-scans.mjs) and by the
|
|
31
|
+
// credentialed verifiers (scripts/ci/scan-receipt.mjs), so a verifier recomputes what a receipt must contain from the
|
|
32
|
+
// authenticated source and never trusts the plan the producer recorded. Dependency-free by design (see the header).
|
|
33
|
+
|
|
34
|
+
export const rustScanId = Object.freeze({
|
|
35
|
+
check: (name) => `check:${name}`,
|
|
36
|
+
checkFeatures: (name, index) => `check-features:${name}:${index}`,
|
|
37
|
+
clippy: (name, count, index) => (count === 1 ? `clippy:${name}` : `clippy:${name}:${index}`),
|
|
38
|
+
doc: (name) => `doc:${name}`,
|
|
39
|
+
testRust: (name, count, index) => `test:rust:${name}${count === 1 ? "" : `:${index}`}`,
|
|
40
|
+
testRustSerial: (name, count, index) => `test:rust-serial:${name}${count === 1 ? "" : `:${index}`}`,
|
|
41
|
+
packageList: (name) => `cargo-package-list:${name}`,
|
|
42
|
+
changelog: (name) => `changelog-version:${name}`,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A crate's compile feature plan exactly as CI's gate runs it (ci/gate.config.json rust.<crate>): `lintSets` (clippy; default
|
|
47
|
+
* features when none), whether a default `check` is needed (no lint set covers default features), and the `checkSets` that
|
|
48
|
+
* remain (none when a lint set already uses --all-features).
|
|
49
|
+
*/
|
|
50
|
+
export function rustCratePlan(own = {}) {
|
|
51
|
+
const lintSets = Array.isArray(own?.featureSets) && own.featureSets.length ? own.featureSets : [[]];
|
|
52
|
+
const defaultCheck = !lintSets.some((set) => set.length === 0);
|
|
53
|
+
const checkSets = (lintSets.some((set) => set.includes("--all-features")) ? [] : (Array.isArray(own?.checkFeatureSets) ? own.checkFeatureSets : [["--all-features"]])).filter((set) => set.length);
|
|
54
|
+
return { lintSets, defaultCheck, checkSets };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Scan ids every scan of the whole repository always has when it holds Rust units (cargo.toml, workflows, YAML, JSON and licences). */
|
|
58
|
+
export const WORKSPACE_SCAN_IDS = Object.freeze(["cargo-metadata", "cargo-deny", "cargo-audit", "actionlint", "yaml-parse", "toml-parse", "json-parse", "license", "secret-scan"]);
|
|
59
|
+
/** Prefixes at least one scan id must carry (rustfmt groups by edition, so its exact ids depend on the files). */
|
|
60
|
+
export const REQUIRED_SCAN_PREFIXES = Object.freeze(["rustfmt:"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The scan ids a LOCAL scan record must contain, each with status pass, for these publishable Rust `units` ([names], crates/<name>).
|
|
64
|
+
* `exists(repoPath)` answers from the AUTHENTICATED source (never from the record): src/lib.rs decides `doc`, CHANGELOG.md decides
|
|
65
|
+
* `changelog-version`, and the repository's own ci/advisory-exceptions.json, ownership catalog and docs generator decide theirs.
|
|
66
|
+
* `crateFeatures` is ci/gate.config.json `rust`. `includeTests` adds the per-feature-set test scans. Extra scans in a record are
|
|
67
|
+
* allowed; a missing one is not. planScans builds its ids from the same helpers, and a test holds the two in agreement.
|
|
68
|
+
*/
|
|
69
|
+
export function requiredScanIds({ units, crateFeatures = {}, exists, includeTests = false }) {
|
|
70
|
+
if (!Array.isArray(units) || units.length === 0 || !units.every((n) => typeof n === "string" && n)) throw new TypeError("requiredScanIds: units must be a non-empty array of names");
|
|
71
|
+
if (typeof exists !== "function") throw new TypeError("requiredScanIds: exists(repoPath) is required");
|
|
72
|
+
const ids = new Set(WORKSPACE_SCAN_IDS);
|
|
73
|
+
if (exists("ci/advisory-exceptions.json")) ids.add("advisory-exceptions");
|
|
74
|
+
if (exists("packages/release/rightkit-ownership.json") && exists("crates/rightkit-ownership/catalog/ownership-catalog.json")) ids.add("ownership-catalog");
|
|
75
|
+
if (exists("scripts/generate-index.mjs")) ids.add("generated-docs");
|
|
76
|
+
for (const name of units) {
|
|
77
|
+
const own = crateFeatures?.[name] ?? {};
|
|
78
|
+
const { lintSets, defaultCheck, checkSets } = rustCratePlan(own);
|
|
79
|
+
ids.add(rustScanId.packageList(name));
|
|
80
|
+
if (exists(`crates/${name}/CHANGELOG.md`)) ids.add(rustScanId.changelog(name));
|
|
81
|
+
if (defaultCheck) ids.add(rustScanId.check(name));
|
|
82
|
+
checkSets.forEach((_set, i) => ids.add(rustScanId.checkFeatures(name, i)));
|
|
83
|
+
lintSets.forEach((_set, i) => ids.add(rustScanId.clippy(name, lintSets.length, i)));
|
|
84
|
+
if (exists(`crates/${name}/src/lib.rs`)) ids.add(rustScanId.doc(name));
|
|
85
|
+
if (includeTests) {
|
|
86
|
+
lintSets.forEach((_set, i) => {
|
|
87
|
+
ids.add(rustScanId.testRust(name, lintSets.length, i));
|
|
88
|
+
if (own.alsoSerial) ids.add(rustScanId.testRustSerial(name, lintSets.length, i));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [...ids].sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Why `rows` (a record's scans) do not contain every required id with status pass; empty when they do. */
|
|
96
|
+
export function requiredScanProblems(rows, required, prefixes = REQUIRED_SCAN_PREFIXES) {
|
|
97
|
+
const byId = new Map();
|
|
98
|
+
for (const row of Array.isArray(rows) ? rows : []) if (row && typeof row.id === "string") byId.set(row.id, row);
|
|
99
|
+
const problems = [];
|
|
100
|
+
for (const id of required) {
|
|
101
|
+
const row = byId.get(id);
|
|
102
|
+
if (!row) problems.push(`required scan ${id} is missing`);
|
|
103
|
+
else if (row.status !== "pass") problems.push(`required scan ${id} is ${row.status}`);
|
|
104
|
+
}
|
|
105
|
+
for (const prefix of prefixes) if (![...byId.values()].some((row) => row.id.startsWith(prefix) && row.status === "pass")) problems.push(`no passing ${prefix}* scan`);
|
|
106
|
+
return problems;
|
|
107
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// right-release sign-qa-bundle: QA-ONLY ad hoc signing of an already-assembled .app that lives inside a QA scratch root.
|
|
3
|
+
// Why it exists: an assembled QA bundle (for example an Accessibility QA copy) needs a coherent ad hoc signature, but
|
|
4
|
+
// `codesign --force --deep --sign -` routed through the signing agent (SSH or non-Aqua session) is refused by design
|
|
5
|
+
// (macos-signing-session.mjs codesignAgentItems; rightkit-signing-agent.mjs). Those refusals stay. This module never calls the
|
|
6
|
+
// agent: it spawns /usr/bin/codesign itself, signs nested code one item at a time, deepest first, the outer bundle last, with
|
|
7
|
+
// identity "-" and never `--deep`. Ad hoc output is not a release artifact: it is never notarized, sealed, published or installed
|
|
8
|
+
// under /Applications, and the receipt says so (signature "adhoc", releaseArtifact false, qaOnly true).
|
|
9
|
+
import { existsSync, lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { spawnSync } from "./background-process.mjs";
|
|
14
|
+
import { adhocSafeEntitlements } from "./adhoc-entitlements.mjs";
|
|
15
|
+
|
|
16
|
+
export const QA_BUNDLE_SIGNING_SCHEMA = 1;
|
|
17
|
+
export const CODESIGN = "/usr/bin/codesign";
|
|
18
|
+
const PLUTIL = "/usr/bin/plutil";
|
|
19
|
+
// macOS paths only: posix semantics keep the checks identical on every host that unit-tests this module.
|
|
20
|
+
const px = path.posix;
|
|
21
|
+
|
|
22
|
+
export class QaBundleSigningError extends Error {
|
|
23
|
+
constructor(code, message, details = {}) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "QaBundleSigningError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
Object.assign(this, details);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const fail = (code, message, details) => { throw new QaBundleSigningError(code, message, details); };
|
|
31
|
+
|
|
32
|
+
const defaultIo = { lstatSync, readdirSync, realpathSync, existsSync, mkdtempSync, writeFileSync, rmSync };
|
|
33
|
+
const captureOptions = { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false };
|
|
34
|
+
const outputOf = (result) => `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
35
|
+
const inside = (parent, child) => child !== parent && child.startsWith(parent === "/" ? "/" : `${parent}/`);
|
|
36
|
+
|
|
37
|
+
function checked(run, args, code, command = CODESIGN) {
|
|
38
|
+
const result = run(command, args, captureOptions);
|
|
39
|
+
if (!result || result.error || result.status !== 0) {
|
|
40
|
+
fail(code, `${command} ${args[0]} failed (${result?.status ?? "unavailable"}): ${result?.error?.message || outputOf(result ?? {})}`, { status: result?.status });
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function lstatOrNull(io, file) { try { return io.lstatSync(file); } catch { return null; } }
|
|
46
|
+
|
|
47
|
+
function requireAbsolute(label, value) {
|
|
48
|
+
if (typeof value !== "string" || !value.startsWith("/")) fail("QA_SIGN_PATH_NOT_ABSOLUTE", `${label} must be an absolute macOS path: ${JSON.stringify(value)}`);
|
|
49
|
+
if (value.split("/").includes("..") || value.includes("\0")) fail("QA_SIGN_PATH_NOT_NORMAL", `${label} must not contain ".." segments: ${JSON.stringify(value)}`);
|
|
50
|
+
return value.length > 1 ? value.replace(/\/+$/, "") : value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Validate the target and return its real paths. Refusals are typed and happen before any codesign call. */
|
|
54
|
+
export function resolveQaBundleTarget({ app, qaScratchRoot, platform = process.platform, home = os.homedir(), io = defaultIo }) {
|
|
55
|
+
if (platform !== "darwin") fail("QA_SIGN_DARWIN_ONLY", `sign-qa-bundle runs on macOS only (platform ${platform})`);
|
|
56
|
+
const lexicalApp = requireAbsolute("--app", app);
|
|
57
|
+
const lexicalRoot = requireAbsolute("--qa-root", qaScratchRoot);
|
|
58
|
+
const lexicalHome = requireAbsolute("home", home);
|
|
59
|
+
const applications = ["/Applications", px.join(lexicalHome, "Applications")];
|
|
60
|
+
const real = (label, file) => { try { return io.realpathSync(file); } catch { return fail("QA_SIGN_PATH_MISSING", `${label} does not exist: ${file}`); } };
|
|
61
|
+
const realApplications = applications.flatMap((dir) => { const out = [dir]; try { out.push(io.realpathSync(dir)); } catch { /* absent */ } return out; });
|
|
62
|
+
const inApplications = (file) => realApplications.some((dir) => file === dir || inside(dir, file));
|
|
63
|
+
if (inApplications(lexicalApp)) fail("QA_SIGN_APPLICATIONS_REFUSED", `QA signing refuses installed apps (/Applications, ~/Applications): ${lexicalApp}`);
|
|
64
|
+
if (px.extname(lexicalApp) !== ".app") fail("QA_SIGN_NOT_APP", `target is not a .app bundle: ${lexicalApp}`);
|
|
65
|
+
const link = lstatOrNull(io, lexicalApp);
|
|
66
|
+
if (!link) fail("QA_SIGN_PATH_MISSING", `--app does not exist: ${lexicalApp}`);
|
|
67
|
+
if (link.isSymbolicLink()) fail("QA_SIGN_LINK_REFUSED", `--app is a symbolic link; pass the real bundle: ${lexicalApp}`);
|
|
68
|
+
if (!link.isDirectory()) fail("QA_SIGN_NOT_APP", `target is not a directory bundle: ${lexicalApp}`);
|
|
69
|
+
const realRoot = real("--qa-root", lexicalRoot);
|
|
70
|
+
const realApp = real("--app", lexicalApp);
|
|
71
|
+
if (realRoot === "/" || realRoot === lexicalHome || inApplications(realRoot) || realApplications.some((dir) => inside(realRoot, dir))) {
|
|
72
|
+
fail("QA_SIGN_ROOT_REFUSED", `QA scratch root is too broad or an installed-app location: ${realRoot}`);
|
|
73
|
+
}
|
|
74
|
+
if (inApplications(realApp)) fail("QA_SIGN_APPLICATIONS_REFUSED", `QA signing refuses installed apps (resolved to ${realApp})`);
|
|
75
|
+
if (!inside(realRoot, realApp)) fail("QA_SIGN_OUTSIDE_QA_ROOT", `bundle ${realApp} is not inside the QA scratch root ${realRoot}`);
|
|
76
|
+
if (px.extname(realApp) !== ".app") fail("QA_SIGN_NOT_APP", `target resolves to a non-.app path: ${realApp}`);
|
|
77
|
+
return { app: lexicalApp, realApp, realRoot };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Bundle kinds that are signed as a unit, and the directory whose files are loose Mach-O candidates. */
|
|
81
|
+
const BUNDLE_EXTENSIONS = new Set([".app", ".xpc", ".framework"]);
|
|
82
|
+
|
|
83
|
+
function readInfo(run, bundle, cache, io) {
|
|
84
|
+
if (cache.has(bundle)) return cache.get(bundle);
|
|
85
|
+
let info = {};
|
|
86
|
+
for (const location of ["Contents/Info.plist", "Resources/Info.plist", "Info.plist"]) {
|
|
87
|
+
const file = px.join(bundle, location);
|
|
88
|
+
if (!io.existsSync(file)) continue;
|
|
89
|
+
const result = checked(run, ["-convert", "json", "-o", "-", file], "QA_SIGN_PLIST_INVALID", PLUTIL);
|
|
90
|
+
try { info = JSON.parse(result.stdout); } catch { fail("QA_SIGN_PLIST_INVALID", `Invalid Info.plist JSON: ${file}`); }
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
cache.set(bundle, info);
|
|
94
|
+
return info;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Nested code inside the bundle, ordered inside-out: deepest path first (stable by path), the outer bundle excluded.
|
|
99
|
+
* Symbolic links are never entered or signed; a link that resolves outside the bundle is refused.
|
|
100
|
+
*/
|
|
101
|
+
export function enumerateNestedCode({ realApp, run, io = defaultIo }) {
|
|
102
|
+
const infoCache = new Map();
|
|
103
|
+
const found = new Map();
|
|
104
|
+
const add = (file, kind) => { if (!found.has(file)) found.set(file, kind); };
|
|
105
|
+
const bundleOf = (dir) => px.dirname(px.dirname(dir)); // .../X.app/Contents/MacOS -> .../X.app
|
|
106
|
+
const walk = (dir) => {
|
|
107
|
+
for (const entry of io.readdirSync(dir, { withFileTypes: true })) {
|
|
108
|
+
const file = px.join(dir, entry.name);
|
|
109
|
+
if (entry.isSymbolicLink()) {
|
|
110
|
+
let target;
|
|
111
|
+
try { target = io.realpathSync(file); } catch { fail("QA_SIGN_LINK_REFUSED", `dangling symbolic link inside the bundle: ${file}`); }
|
|
112
|
+
if (target !== realApp && !inside(realApp, target)) fail("QA_SIGN_LINK_REFUSED", `symbolic link escapes the bundle: ${file} -> ${target}`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const ext = px.extname(entry.name);
|
|
116
|
+
if (entry.isDirectory()) {
|
|
117
|
+
if (BUNDLE_EXTENSIONS.has(ext)) add(file, ext === ".framework" ? "framework" : ext === ".xpc" ? "xpc" : "app");
|
|
118
|
+
else if (px.basename(dir) === "LoginItems" && px.basename(px.dirname(dir)) === "Library") add(file, "login-item");
|
|
119
|
+
walk(file);
|
|
120
|
+
} else if (entry.isFile()) {
|
|
121
|
+
if (ext === ".dylib") add(file, "dylib");
|
|
122
|
+
else if (px.basename(dir) === "MacOS" && px.basename(px.dirname(dir)) === "Contents") {
|
|
123
|
+
const main = readInfo(run, bundleOf(dir), infoCache, io).CFBundleExecutable;
|
|
124
|
+
if (typeof main !== "string" || !main) fail("QA_SIGN_PLIST_INVALID", `no CFBundleExecutable for ${bundleOf(dir)}`);
|
|
125
|
+
if (entry.name !== main) add(file, "executable");
|
|
126
|
+
} else if (px.basename(dir) === "Frameworks" && px.basename(px.dirname(dir)) === "Contents") add(file, "executable");
|
|
127
|
+
else if (px.basename(dir) === "LoginItems" && px.basename(px.dirname(dir)) === "Library") add(file, "login-item");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
walk(realApp);
|
|
132
|
+
const depth = (file) => file.split("/").length;
|
|
133
|
+
return [...found].map(([file, kind]) => ({ path: file, kind }))
|
|
134
|
+
.sort((a, b) => depth(b.path) - depth(a.path) || (a.path < b.path ? -1 : 1));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The only codesign argv this module emits. `--deep` and any identity other than "-" are structurally impossible. */
|
|
138
|
+
export function adhocCodesignArgs(item, entitlementsFile) {
|
|
139
|
+
const args = ["--force", "--sign", "-"];
|
|
140
|
+
if (entitlementsFile) args.push("--entitlements", entitlementsFile);
|
|
141
|
+
args.push(item);
|
|
142
|
+
if (args.includes("--deep") || args[args.indexOf("--sign") + 1] !== "-") fail("QA_SIGN_ARGV_REFUSED", "internal: ad hoc argv must never carry --deep or a real identity");
|
|
143
|
+
return args;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function signQaBundleAdhoc({ app, qaScratchRoot, run = spawnSync, platform = process.platform, home = os.homedir(), entitlements, io = defaultIo, out } = {}) {
|
|
147
|
+
const target = resolveQaBundleTarget({ app, qaScratchRoot, platform, home, io });
|
|
148
|
+
const { realApp, realRoot } = target;
|
|
149
|
+
let scratch;
|
|
150
|
+
try {
|
|
151
|
+
let outerEntitlements;
|
|
152
|
+
let entitlementKeys = [];
|
|
153
|
+
if (entitlements !== undefined) {
|
|
154
|
+
const source = requireAbsolute("entitlements", entitlements);
|
|
155
|
+
const parsed = checked(run, ["-convert", "json", "-o", "-", source], "QA_SIGN_ENTITLEMENTS_INVALID", PLUTIL);
|
|
156
|
+
let reduced;
|
|
157
|
+
try { reduced = adhocSafeEntitlements(JSON.parse(parsed.stdout)); } catch { fail("QA_SIGN_ENTITLEMENTS_INVALID", `Invalid entitlements plist: ${source}`); }
|
|
158
|
+
entitlementKeys = Object.keys(reduced).sort();
|
|
159
|
+
if (entitlementKeys.length) {
|
|
160
|
+
scratch = io.mkdtempSync(px.join(realRoot, ".sign-qa-"));
|
|
161
|
+
outerEntitlements = px.join(scratch, "adhoc-entitlements.plist");
|
|
162
|
+
io.writeFileSync(outerEntitlements, JSON.stringify(reduced));
|
|
163
|
+
checked(run, ["-convert", "xml1", outerEntitlements], "QA_SIGN_ENTITLEMENTS_INVALID", PLUTIL);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const nested = enumerateNestedCode({ realApp, run, io });
|
|
167
|
+
const plan = [...nested, { path: realApp, kind: "bundle" }];
|
|
168
|
+
const signed = [];
|
|
169
|
+
for (const item of plan) {
|
|
170
|
+
const withEntitlements = item.kind === "bundle" ? outerEntitlements : undefined;
|
|
171
|
+
checked(run, adhocCodesignArgs(item.path, withEntitlements), "QA_SIGN_CODESIGN_FAILED");
|
|
172
|
+
signed.push({ path: item.path, kind: item.kind });
|
|
173
|
+
}
|
|
174
|
+
checked(run, ["--verify", "--strict", realApp], "QA_SIGN_VERIFY_FAILED");
|
|
175
|
+
const receipt = {
|
|
176
|
+
schema: QA_BUNDLE_SIGNING_SCHEMA, signature: "adhoc", releaseArtifact: false, qaOnly: true,
|
|
177
|
+
notarized: false, published: false, usedSigningAgent: false,
|
|
178
|
+
app: realApp, qaScratchRoot: realRoot, entitlementKeys, verified: "codesign --verify --strict", signed,
|
|
179
|
+
};
|
|
180
|
+
if (out) {
|
|
181
|
+
const file = requireAbsolute("--out", out);
|
|
182
|
+
io.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
183
|
+
}
|
|
184
|
+
return receipt;
|
|
185
|
+
} finally {
|
|
186
|
+
if (scratch) { try { io.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function main(args = process.argv.slice(2), options = {}) {
|
|
191
|
+
const take = (flag) => {
|
|
192
|
+
const index = args.indexOf(flag);
|
|
193
|
+
if (index < 0) return undefined;
|
|
194
|
+
const value = args[index + 1];
|
|
195
|
+
if (!value || value.startsWith("--")) fail("QA_SIGN_USAGE", `${flag} requires a value`);
|
|
196
|
+
return value;
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
for (const arg of args.filter((a) => a.startsWith("--"))) {
|
|
200
|
+
if (!["--app", "--qa-root", "--out", "--entitlements"].includes(arg)) fail("QA_SIGN_USAGE", `unknown option ${arg}`);
|
|
201
|
+
}
|
|
202
|
+
const receipt = signQaBundleAdhoc({ app: take("--app"), qaScratchRoot: take("--qa-root"), entitlements: take("--entitlements"), out: take("--out"), ...options });
|
|
203
|
+
console.log(`ad hoc signed (QA only, not a release artifact) ${receipt.signed.length} item(s), outer bundle last: ${receipt.app}`);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.error(`right-release sign-qa-bundle: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) main();
|