@rightkit/release 0.6.16 → 0.6.21
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 +7 -3
- package/admission-host.mjs +9 -5
- package/app-closure.mjs +9 -3
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +481 -103
- package/bin/rightkit-linux-setup.mjs +15 -0
- package/broker-admission.mjs +184 -0
- package/broker-wire.mjs +15 -3
- package/build-launch.mjs +4 -1
- package/build-ownership.mjs +125 -36
- package/build-release.mjs +39 -7
- package/cargo-contract.mjs +4 -1
- package/cargo-guard.mjs +25 -5
- package/cargo-target-shim.mjs +56 -7
- package/clippy-route.mjs +8 -7
- package/crate-graph.mjs +5 -1
- package/credential-provider.mjs +174 -0
- package/deterministic-scans.mjs +253 -42
- package/development.mjs +8 -0
- package/disk-policy.mjs +2 -2
- package/gate-executor.mjs +5 -3
- package/gate-stages.mjs +34 -3
- package/gradle-lock.mjs +206 -0
- package/heavy-command.mjs +33 -2
- package/linux-artifacts.mjs +2 -1
- package/linux-setup.mjs +106 -0
- package/local-publish.mjs +21 -10
- package/local-qualify.mjs +9 -3
- package/managed-cargo.mjs +54 -18
- package/ownership-lockfiles.mjs +14 -3
- package/ownership.mjs +5 -1
- package/package.json +17 -11
- package/protected-credentials.mjs +13 -5
- package/publication-gate.mjs +9 -4
- package/python-lock.mjs +209 -0
- package/qa-admission-policy.json +32 -0
- package/qa-layout.mjs +229 -0
- package/qa-runner.mjs +156 -0
- package/release-artifact-path.mjs +29 -3
- package/release-config.mjs +1 -1
- package/release-qa-command.mjs +58 -8
- package/release-qa-policy.mjs +5 -3
- package/release-source-identity.mjs +1 -0
- package/release.mjs +10 -2
- package/rightkit-ownership.json +1465 -79
- package/rightkit-registry-resolver.mjs +7 -3
- package/rightkit-versions.json +173 -36
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +5 -1
- package/shell-contract.mjs +1 -1
- package/snapshot-broker.mjs +114 -9
- package/snapshot-check.mjs +6 -1
- package/snapshot-request.mjs +20 -3
- package/snapshot.mjs +11 -4
- package/stage-commands.mjs +2 -1
- package/stage-ledger.mjs +15 -0
- package/stage-sidecar-command.mjs +7 -1
- package/stage-sidecar.mjs +17 -3
- package/suite-signing.mjs +40 -4
- package/swift-lock.mjs +334 -0
- package/test-command.mjs +13 -3
- package/tool-build.mjs +156 -0
- package/unit-gate.mjs +9 -2
- package/windows-identity-plan.mjs +119 -0
- package/windows-identity.mjs +136 -0
package/snapshot-check.mjs
CHANGED
|
@@ -231,6 +231,8 @@ export function installSnapshotDependencies({ checkout, snapshot, commit, tree,
|
|
|
231
231
|
const relative = path.relative(base, path.resolve(base, selected)).split(path.sep).join("/");
|
|
232
232
|
const actual = manifests.get(fold(relative));
|
|
233
233
|
const owner = nearestLock(fold(relative));
|
|
234
|
+
// A package under an install root that commits no pnpm-lock.yaml has nothing to install from: not an error here (the root loop below records it as skipped and the pnpm-lockfile scan judges it).
|
|
235
|
+
if (actual !== undefined && owner === undefined && [...installRoots].some((r) => !r || fold(relative) === r || fold(relative).startsWith(`${r}/`))) continue;
|
|
234
236
|
if (actual === undefined || owner === undefined || !installRoots.has(owner) || /[*?\[\]{}!^()\\]/.test(actual) || actual.includes("...")) throw new CheckError(`dependency package ${selected} is not an exact committed manifest directory owned by an install root`);
|
|
235
237
|
if (!selectedByRoot.has(owner)) selectedByRoot.set(owner, new Set());
|
|
236
238
|
selectedByRoot.get(owner).add(actual);
|
|
@@ -303,7 +305,10 @@ export function installSnapshotDependencies({ checkout, snapshot, commit, tree,
|
|
|
303
305
|
const ms = now() - started;
|
|
304
306
|
if (failed(r) || /No projects matched the filters/.test(`${r?.stdout ?? ""}\n${r?.stderr ?? ""}`)) {
|
|
305
307
|
const out = `${r?.stdout ?? ""}\n${r?.stderr ?? ""}`;
|
|
306
|
-
|
|
308
|
+
// pnpm 12 prints the code on its own line and wraps the tarball URL ("from https://\n registry.npmjs.org/..."): the URL is rejoined first,
|
|
309
|
+
// and the code and its URL may be a few lines apart (Dell, 2026-11-02: the specific message never fired; only progress lines showed).
|
|
310
|
+
const joined = out.replace(/(https?:\/\/)\s+/g, "$1");
|
|
311
|
+
const miss = /ERR_PNPM_NO_OFFLINE_TARBALL[\s\S]{0,600}?(https?:\/\/\S+?\/-\/(\S+?)\.tgz)/.exec(joined);
|
|
307
312
|
const message = miss
|
|
308
313
|
? `root ${root}: ${miss[2]} is missing from the store ${storeDir} (offline install, no network fallback); install the checkout's dependencies so the store holds it, then run the check again`
|
|
309
314
|
: `root ${root}: offline dependency install failed (store ${storeDir}): ${tail(r) || r?.error?.message || `exit ${r?.status}`}`;
|
package/snapshot-request.mjs
CHANGED
|
@@ -294,7 +294,10 @@ const lstatOrNull = (p) => { try { return fs.lstatSync(p); } catch (e) { if (e.c
|
|
|
294
294
|
* `..` or empty component, not inside `.git`) and no input that is, or holds, either declaration file. A present but invalid canonical declaration is an
|
|
295
295
|
* error: it is never read as the legacy one.
|
|
296
296
|
*/
|
|
297
|
-
|
|
297
|
+
/** Host platform names a declared input may scope itself to (`{ path, platforms }`). */
|
|
298
|
+
export const DECLARED_INPUT_PLATFORMS = Object.freeze({ darwin: "mac", win32: "win", linux: "linux" });
|
|
299
|
+
|
|
300
|
+
export function readDeclaredInputs(snapshotRoot, { platform = process.platform } = {}) {
|
|
298
301
|
let file = null; let at = null; let meta = null;
|
|
299
302
|
for (const candidate of COMPILE_INPUT_DECLARATIONS) {
|
|
300
303
|
let here = snapshotRoot; let found = true;
|
|
@@ -313,8 +316,21 @@ export function readDeclaredInputs(snapshotRoot) {
|
|
|
313
316
|
let value;
|
|
314
317
|
try { value = JSON.parse(fs.readFileSync(at, "utf8")); } catch (e) { fail("declared-inputs", `${file} is not JSON (${e.message})`); }
|
|
315
318
|
if (!value || typeof value !== "object" || !Array.isArray(value.inputs)) fail("declared-inputs", `${file}: \`inputs\` must be a list of paths`);
|
|
316
|
-
|
|
319
|
+
// An entry is a path (every platform) or { path, platforms }: a platform-scoped input (a Windows-only identity package) is declared
|
|
320
|
+
// only on the hosts it names, so another host neither requires nor digests it. Unknown keys or platform names refuse.
|
|
321
|
+
const host = DECLARED_INPUT_PLATFORMS[platform] ?? platform;
|
|
322
|
+
const kept = [];
|
|
323
|
+
for (const entry of value.inputs) {
|
|
324
|
+
let input = entry;
|
|
325
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
326
|
+
const extra = Object.keys(entry).filter((k) => k !== "path" && k !== "platforms");
|
|
327
|
+
if (extra.length) fail("declared-inputs", `${file}: an input object has only path and platforms (found ${extra.join(", ")})`);
|
|
328
|
+
const names = Object.values(DECLARED_INPUT_PLATFORMS);
|
|
329
|
+
if (!Array.isArray(entry.platforms) || !entry.platforms.length || entry.platforms.some((x) => !names.includes(x))) fail("declared-inputs", `${file}: ${JSON.stringify(entry.path)}: platforms must be a non-empty list of ${names.join(", ")}`);
|
|
330
|
+
input = entry.path;
|
|
331
|
+
}
|
|
317
332
|
if (typeof input !== "string" || !input) fail("declared-inputs", `${file}: every input is a non-empty relative path`);
|
|
333
|
+
const here = !(entry && typeof entry === "object") || entry.platforms.includes(host);
|
|
318
334
|
const components = input.split(/[\\/]/);
|
|
319
335
|
if (path.isAbsolute(input) || path.posix.isAbsolute(input) || path.win32.isAbsolute(input) || components.some((c) => c === "" || c === "." || c === "..")) fail("declared-inputs", `${file}: ${input} is not a plain relative path inside the snapshot`);
|
|
320
336
|
if (components.some((c) => c.toLowerCase() === ".git")) fail("declared-inputs", `${file}: ${input} is inside .git; repository metadata is never a compile input`);
|
|
@@ -322,8 +338,9 @@ export function readDeclaredInputs(snapshotRoot) {
|
|
|
322
338
|
const parts = declaration.split("/");
|
|
323
339
|
if (components.length <= parts.length && components.every((c, i) => c === parts[i])) fail("declared-inputs", `${file}: ${input} overlaps the declaration file itself (${declaration})`);
|
|
324
340
|
}
|
|
341
|
+
if (here) kept.push(input);
|
|
325
342
|
}
|
|
326
|
-
return
|
|
343
|
+
return kept;
|
|
327
344
|
}
|
|
328
345
|
|
|
329
346
|
/** The inputs a request supplied must be exactly the declaration the snapshot holds (the broker's check): a throw naming the difference. */
|
package/snapshot.mjs
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
// unreadable processes, build-ownership.mjs).
|
|
28
28
|
//
|
|
29
29
|
// Exclusion: one sync per snapshot at a time (`<root>/<repo-key>.lock`, re-checked before every write and before the verdict).
|
|
30
|
-
// It narrows, but cannot close, the window in which two processes both replace one stale lock;
|
|
31
|
-
// from before the sync through the stage (joint plan step 4)
|
|
30
|
+
// It narrows, but cannot close, the window in which two processes both replace one stale lock; the per-repository build ownership
|
|
31
|
+
// record held from before the sync through the stage (joint plan step 4) closes it, and the broker re-verifies the tree.
|
|
32
32
|
|
|
33
33
|
import crypto from "node:crypto";
|
|
34
34
|
import fs from "node:fs";
|
|
@@ -543,6 +543,13 @@ function inspectSnapshot({ dir, entries, checkout, platform = process.platform,
|
|
|
543
543
|
}
|
|
544
544
|
// Every candidate, ledger or new, must be ignored by the commit's own rules, as the neutral oracle judges them.
|
|
545
545
|
const ignored = neutralIgnored(dir, candidates.map((c) => c.path));
|
|
546
|
+
// Git's own semantics for a directory: `git status` shows nothing for one whose every entry is ignored (or that holds nothing), even
|
|
547
|
+
// when no rule names the directory itself (a root rule `src-tauri/gen/schemas/` leaves `src-tauri/gen` unnamed). Judged deepest
|
|
548
|
+
// first, so a directory counts as ignored exactly when every file and subdirectory below it does; a single unignored file still refuses.
|
|
549
|
+
for (const d of candidates.filter((c) => c.type === "dir" && !ignored.has(c.path)).sort((a, b) => b.path.length - a.path.length)) {
|
|
550
|
+
const below = candidates.filter((c) => c.path.startsWith(`${d.path}/`));
|
|
551
|
+
if (below.every((c) => ignored.has(c.path))) ignored.add(d.path);
|
|
552
|
+
}
|
|
546
553
|
for (const c of candidates) if (!ignored.has(c.path)) return problem(`${c.path} is not in the commit's tree and is not ignored by the commit's own ignore rules (the checkout's info/exclude and global excludes do not count)`);
|
|
547
554
|
return { problem: null, admitted: [...candidates].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) };
|
|
548
555
|
}
|
|
@@ -942,11 +949,11 @@ export function snapshotCommand(o, args, deps = {}) {
|
|
|
942
949
|
// The managed root exists (the gate never creates it) and the root is private, BEFORE build ownership is taken: taking it
|
|
943
950
|
// creates directories of its own, and must never be what brings a missing managed root into being.
|
|
944
951
|
guardRoot(root, managedRoot, { device: o.device });
|
|
945
|
-
// A sync replaces snapshot bytes, so it runs only under the
|
|
952
|
+
// A sync replaces snapshot bytes, so it runs only under the repository's build ownership, taken before the first write (joined when
|
|
946
953
|
// this gate already holds it); a verify only reads.
|
|
947
954
|
if (!verify) {
|
|
948
955
|
const repo = repositoryOf(o.cwd);
|
|
949
|
-
ownership = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot }), stage: "snapshot", snapshot: path.join(root, repo.key), commit: resolveCommit(repo.checkout, rev).commit, platform: o.platform, env: o.env });
|
|
956
|
+
ownership = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot, repository: repo.key }), stage: "snapshot", snapshot: path.join(root, repo.key), commit: resolveCommit(repo.checkout, rev).commit, platform: o.platform, env: o.env });
|
|
950
957
|
}
|
|
951
958
|
const r = verify ? verifySnapshot({ checkout: o.cwd, rev, root, managedRoot, device: o.device, ...(envAt >= 0 ? { environment: { expected: args[envAt + 1], compute: deps.environmentDigest } } : {}) })
|
|
952
959
|
: syncSnapshot({ checkout: o.cwd, rev, root, managedRoot, device: o.device });
|
package/stage-commands.mjs
CHANGED
|
@@ -41,7 +41,8 @@ export function expectedStageCommands({ kind, unit, config, manifest, platform }
|
|
|
41
41
|
const pack = [];
|
|
42
42
|
if (kind === "npm") {
|
|
43
43
|
// The swap helper builds and signs a binary into its own directory of the snapshot (a declared stage output), then packs from there like any unit.
|
|
44
|
-
|
|
44
|
+
// A Linux direct origin builds none: the helper is built and signed hosted (publish.yml build-swap/sign-swap).
|
|
45
|
+
if (unit === SWAP_HELPER_PACKAGE && platform !== "linux") pack.push(CARGO(".", SWAP_HELPER_CARGO_ARGS), CARGO(".", SWAP_HELPER_METADATA_ARGS));
|
|
45
46
|
pack.push(PNPM(npmPackArgs(dir, ANY_ABSOLUTE_PATH)));
|
|
46
47
|
} else {
|
|
47
48
|
compile.unshift(CARGO(crateDir, qualifyFmtArgs(unit)));
|
package/stage-ledger.mjs
CHANGED
|
@@ -148,6 +148,21 @@ export function attributionProblems({ before, after, reported, receipt }) {
|
|
|
148
148
|
return problems;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The attribution of a step that reports no file list (an app's web build, `right-release web-build`): the declared compile inputs
|
|
153
|
+
* (`.rightkit/compile-inputs.json`) are the only paths it may write, so every path it added or changed must lie under one of them and
|
|
154
|
+
* nothing may be removed. Anything else is an unattributed write. `declared` are snapshot-relative posix paths.
|
|
155
|
+
*/
|
|
156
|
+
export function declaredOutputProblems({ before, after, declared }) {
|
|
157
|
+
const d = diffEntries(before, after);
|
|
158
|
+
const roots = declared.map((x) => x.replaceAll("\\", "/").replace(/\/+$/, ""));
|
|
159
|
+
const under = (p) => roots.some((r) => p === r || p.startsWith(`${r}/`));
|
|
160
|
+
const problems = [];
|
|
161
|
+
for (const p of [...d.added, ...d.changed]) if (!under(p)) problems.push(`${p} was written by the web build but lies under no declared compile input`);
|
|
162
|
+
for (const p of d.removed) problems.push(`${p} was removed by the web build`);
|
|
163
|
+
return problems;
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
/** Every `path` string a staging receipt or its `--json` result carries (files, their notices), app-relative. */
|
|
152
167
|
export function reportedPaths(result) {
|
|
153
168
|
const out = new Set();
|
|
@@ -74,5 +74,11 @@ async function main() {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
77
|
-
main().catch((error) => {
|
|
77
|
+
main().catch((error) => {
|
|
78
|
+
console.error(`right-release stage-sidecar: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
|
|
79
|
+
// The failed tool's own last words (a captured rustright or cargo stderr), which the message alone does not carry.
|
|
80
|
+
const tail = String(error.details?.stderr ?? "").trim().split(/\r?\n/).slice(-8).join("\n");
|
|
81
|
+
if (tail) console.error(`right-release stage-sidecar: its stderr ended:\n${tail}`);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
});
|
|
78
84
|
}
|
package/stage-sidecar.mjs
CHANGED
|
@@ -603,17 +603,25 @@ export function defaultRun(command, args, { cwd, env = process.env } = {}) {
|
|
|
603
603
|
*/
|
|
604
604
|
const isDebugSymbolMember = (member) => member.split("/").some((part) => part.toLowerCase().endsWith(".dsym"));
|
|
605
605
|
|
|
606
|
-
|
|
606
|
+
/**
|
|
607
|
+
* The tar that lists and unpacks archives. On Windows it is the System32 bsdtar, named explicitly: PATH often finds Git for Windows'
|
|
608
|
+
* GNU tar first, which reads the colon in `D:\...` as a remote host and cannot list the cached archive (as direct-bootstrap.mjs).
|
|
609
|
+
*/
|
|
610
|
+
export function archiveTar({ platform = process.platform, env = process.env } = {}) {
|
|
611
|
+
return platform === "win32" ? path.win32.join(env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe") : "tar";
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function defaultExtractMember({ archive, fileName, destDir, run = defaultRun, tar = archiveTar() }) {
|
|
607
615
|
const wanted = normalizeArchiveMember(fileName);
|
|
608
616
|
if (wanted.error) fail("STAGE_SIDECAR_EXTRACT_FAILED", `archive member ${JSON.stringify(fileName)} ${wanted.error}`);
|
|
609
|
-
const listing = run(
|
|
617
|
+
const listing = run(tar, ["-tf", archive], { cwd: path.dirname(archive) });
|
|
610
618
|
if (listing.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot list ${archive}`);
|
|
611
619
|
const lines = String(listing.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
612
620
|
const members = wanted.byPath
|
|
613
621
|
? lines.filter((line) => (line.startsWith("./") ? line.slice(2) : line) === wanted.value)
|
|
614
622
|
: lines.filter((line) => path.posix.basename(line) === wanted.value && !isDebugSymbolMember(line));
|
|
615
623
|
if (members.length !== 1) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${archive} has ${members.length} members ${wanted.byPath ? "at the path" : "named"} ${wanted.value}, expected exactly one`);
|
|
616
|
-
const extracted = run(
|
|
624
|
+
const extracted = run(tar, ["-xf", archive, "-C", destDir, members[0]], { cwd: path.dirname(archive) });
|
|
617
625
|
if (extracted.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot extract ${members[0]} from ${archive}`);
|
|
618
626
|
const out = path.join(destDir, ...members[0].split("/"));
|
|
619
627
|
const inside = path.relative(path.resolve(destDir), path.resolve(out));
|
|
@@ -1990,6 +1998,12 @@ export async function stageSidecars({ appRoot, manifest, target, cacheRoot, deps
|
|
|
1990
1998
|
};
|
|
1991
1999
|
validateStageSidecarReceipt(receipt, { target: plan.target.triple });
|
|
1992
2000
|
const receiptFile = path.resolve(appRoot, plan.receiptPath);
|
|
2001
|
+
// A receipt that would differ only in `stagedAt` is kept as it is: staging the same target again with the same results (the gate staged it
|
|
2002
|
+
// first in a split, rightkit-gate stageSidecarsOnce) leaves every byte unchanged, so the gate's stage ledger sees no change to its staged outputs.
|
|
2003
|
+
const sameAs = (old) => { const { stagedAt: _a, ...a } = old; const { stagedAt: _b, ...b } = receipt; return JSON.stringify(a) === JSON.stringify(b); };
|
|
2004
|
+
let previous = null;
|
|
2005
|
+
try { previous = JSON.parse(fs.readFileSync(receiptFile, "utf8")); } catch { previous = null; }
|
|
2006
|
+
if (previous && sameAs(previous)) return { receipt: previous, receiptPath: receiptFile, files: previous.files, externalBinNames: previous.files.filter((file) => file.externalBin).map((file) => file.name), unchanged: true };
|
|
1993
2007
|
fs.mkdirSync(path.dirname(receiptFile), { recursive: true });
|
|
1994
2008
|
fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
1995
2009
|
return { receipt, receiptPath: receiptFile, files, externalBinNames: files.filter((file) => file.externalBin).map((file) => file.name) };
|
package/suite-signing.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { signMacosEntries } from "./mac-sign-core.mjs";
|
|
|
34
34
|
import { runMacCodesignCommands } from "./macos-signing-session.mjs";
|
|
35
35
|
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
36
36
|
import { isInside, realish } from "./qa-snapshot.mjs";
|
|
37
|
+
import { GATE_TARGET_DIRS_ENV } from "./qa-layout.mjs";
|
|
37
38
|
import { requireSignerToken, signWindowsFiles, verifyWindowsFiles } from "./sign-windows.mjs";
|
|
38
39
|
import { QA_APP_BINARY_ENV, QA_SIGNED_FILES_ENV, SIGN_SUBJECT, SIGN_TEAM, SuiteSigningError, assertBundleIdMatches, containedRelative, productIdentity } from "./suite-signing-policy.mjs";
|
|
39
40
|
|
|
@@ -52,6 +53,31 @@ function codesignReport(file, { run, env, root }) {
|
|
|
52
53
|
return { identifier: /^Identifier=(.+)$/m.exec(text)?.[1]?.trim(), team: /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(), adhoc: /^Signature=adhoc$/m.test(text) };
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// The QA bundle runs under app.rightkit.qa.<id> with no embedded.provisionprofile, so a provisioning profile bound to the product id cannot apply to it, and
|
|
57
|
+
// AMFI refuses to spawn a bundle that claims a profile-gated entitlement without one (POSIX 163, "Launchd job spawn failed"). Suite (QA) signing therefore
|
|
58
|
+
// drops the profile-gated keys; profile-gated capabilities (iCloud/CloudKit, push) are not exercisable in Mac source QA. Release signing never goes through here.
|
|
59
|
+
const isProfileGatedEntitlement = (key) => key === "com.apple.application-identifier" || key === "com.apple.developer.team-identifier" || key.startsWith("com.apple.developer.") || key === "aps-environment" || key === "keychain-access-groups";
|
|
60
|
+
|
|
61
|
+
/** The entitlements file to sign `index` with: the original when no key is profile-gated, else a derived plist in signedDir without them. */
|
|
62
|
+
function qaEntitlements({ file, index, signedDir, run, env, root }) {
|
|
63
|
+
const options = { cwd: root, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false };
|
|
64
|
+
const failed = (why) => refuse("SIGN_ENTITLEMENTS_UNREADABLE", `cannot read the entitlements ${file}: ${why}`, { file });
|
|
65
|
+
const read = run("plutil", ["-convert", "json", "-o", "-", file], options);
|
|
66
|
+
if (read?.error || read?.status !== 0) failed(read?.error?.message || output(read).trim() || `plutil exit ${read?.status ?? "unavailable"}`);
|
|
67
|
+
let parsed;
|
|
68
|
+
try { parsed = JSON.parse(read.stdout); } catch (cause) { failed(`plutil did not return JSON (${cause.message})`); }
|
|
69
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) failed("the plist is not a dictionary");
|
|
70
|
+
const dropped = Object.keys(parsed).filter(isProfileGatedEntitlement);
|
|
71
|
+
if (!dropped.length) return { file, dropped };
|
|
72
|
+
const derivedDir = path.join(signedDir, "qa-entitlements");
|
|
73
|
+
fs.mkdirSync(derivedDir, { recursive: true });
|
|
74
|
+
const derived = path.join(derivedDir, `${index}.plist`);
|
|
75
|
+
fs.writeFileSync(derived, JSON.stringify(Object.fromEntries(Object.entries(parsed).filter(([key]) => !isProfileGatedEntitlement(key)))));
|
|
76
|
+
const write = run("plutil", ["-convert", "xml1", derived], options);
|
|
77
|
+
if (write?.error || write?.status !== 0) failed(`cannot write ${derived}: ${write?.error?.message || output(write).trim() || `plutil exit ${write?.status ?? "unavailable"}`}`);
|
|
78
|
+
return { file: derived, dropped };
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
/** Mac: sign each copy and read it back against the release-shaped identity. Returns one evidence record per file. */
|
|
56
82
|
function signMac({ copies, name, config, appRoot, env, run, deps, identifier, install, signedDir, mainIndex }) {
|
|
57
83
|
const settings = readDevSigningSettings(install, config, { root: appRoot });
|
|
@@ -78,6 +104,13 @@ function signMac({ copies, name, config, appRoot, env, run, deps, identifier, in
|
|
|
78
104
|
const entitlements = (index === mainIndex ? lookupBundlePath(settings.entitlements, ".") : undefined) ?? lookupBundlePath(settings.entitlements, relative);
|
|
79
105
|
return { file: copy.file, ...(id !== undefined ? { identifier: id } : {}), expectedIdentifier: id ?? path.basename(copy.file), ...(entitlements ? { entitlements } : {}) };
|
|
80
106
|
});
|
|
107
|
+
entries.forEach((entry, index) => {
|
|
108
|
+
entry.entitlementsDropped = [];
|
|
109
|
+
if (!entry.entitlements) return;
|
|
110
|
+
const derived = qaEntitlements({ file: entry.entitlements, index, signedDir, run, env, root: appRoot });
|
|
111
|
+
entry.entitlements = derived.file;
|
|
112
|
+
entry.entitlementsDropped = derived.dropped;
|
|
113
|
+
});
|
|
81
114
|
const signOne = deps.signOne ?? ((args) => runMacCodesignCommands([args], { env, run, cwd: appRoot })[0]);
|
|
82
115
|
const inspect = deps.inspect ?? ((command, args) => {
|
|
83
116
|
const result = run(command, args, { cwd: appRoot, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
|
|
@@ -85,7 +118,7 @@ function signMac({ copies, name, config, appRoot, env, run, deps, identifier, in
|
|
|
85
118
|
return output(result);
|
|
86
119
|
});
|
|
87
120
|
let records;
|
|
88
|
-
try { records = signMacosEntries(entries.map(({ expectedIdentifier: _derived, ...entry }) => entry), { identity: signing.identity, signOne, inspect, bundle: signedDir }); }
|
|
121
|
+
try { records = signMacosEntries(entries.map(({ expectedIdentifier: _derived, entitlementsDropped: _dropped, ...entry }) => entry), { identity: signing.identity, signOne, inspect, bundle: signedDir }); }
|
|
89
122
|
catch (cause) { refuse("SIGN_FAILED", `${name}: ${cause.message}`, { cause }); }
|
|
90
123
|
return records.map((record, index) => {
|
|
91
124
|
const entry = entries[index];
|
|
@@ -99,7 +132,7 @@ function signMac({ copies, name, config, appRoot, env, run, deps, identifier, in
|
|
|
99
132
|
catch (error) { if (!(error instanceof MacosDevelopmentSigningError)) throw error; refuse("SIGN_REQUIREMENT_MISMATCH", `${copies[index].path}: ${error.message}`, { expected, actual: error.installedRequirement }); }
|
|
100
133
|
// assertStableDesignatedRequirement is silent about an unreadable or cdhash-bound requirement (it has no grants to protect); here either is a refusal.
|
|
101
134
|
if (requirement !== expected) refuse("SIGN_REQUIREMENT_MISMATCH", `${copies[index].path}: the designated requirement could not be read back, or is cdhash-bound, instead of ${JSON.stringify(expected)}`, { expected });
|
|
102
|
-
return { platform: "mac", file: copies[index].path, signed: record.file, sha256Before: record.before.sha256, sha256After: record.after.sha256, identifier: entry.expectedIdentifier, team: report.team, identity: signing.identity, requirement };
|
|
135
|
+
return { platform: "mac", file: copies[index].path, signed: record.file, sha256Before: record.before.sha256, sha256After: record.after.sha256, identifier: entry.expectedIdentifier, team: report.team, identity: signing.identity, requirement, entitlementsDropped: entry.entitlementsDropped };
|
|
103
136
|
});
|
|
104
137
|
}
|
|
105
138
|
|
|
@@ -164,7 +197,9 @@ export function runSignStep({ name, sign, appRoot, config, env = process.env, pl
|
|
|
164
197
|
const { identifier, mainBinaryName } = productIdentity({ readFile: read, manifestPath: install.manifestPath });
|
|
165
198
|
assertBundleIdMatches(config, identifier);
|
|
166
199
|
// 2. Where each file comes from.
|
|
167
|
-
|
|
200
|
+
// A broker host names a suite's outputs by its carried receipts; a direct host by the output directories the gate pinned for the stage
|
|
201
|
+
// (RIGHTKIT_GATE_TARGET_DIRS, test-command.mjs). With neither, nothing says where a suite's files are, and nothing is signed.
|
|
202
|
+
if (!carried?.map && !env[GATE_TARGET_DIRS_ENV]) refuse("SIGN_RECEIPT_MISSING", `${name}: no carried receipts in this environment (${carried?.error ?? "RIGHTKIT_CARRIED_CARGO_MAP is not set"}) and no gate-pinned output directories (${GATE_TARGET_DIRS_ENV}); a sign step signs a file an earlier suite built`);
|
|
168
203
|
if (typeof outputDirsOf !== "function") refuse("SIGN_RECEIPT_MISSING", `${name}: the run has no receipt reader`);
|
|
169
204
|
const sources = sign.files.map((item, index) => {
|
|
170
205
|
const file = { ...item, path: containedRelative(item.path, `${name}.files[${index}].path`) };
|
|
@@ -175,7 +210,8 @@ export function runSignStep({ name, sign, appRoot, config, env = process.env, pl
|
|
|
175
210
|
// 3. A gate-managed copy, outside everything the build owns.
|
|
176
211
|
const signingRoot = deps.signingDirectory ?? defaultSigningDirectory({ env, platform });
|
|
177
212
|
signedDir = path.join(signingRoot, `${name}-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`);
|
|
178
|
-
|
|
213
|
+
// A direct host (no carrier) has no broker section: the checkout and the suites' own output directories are still forbidden.
|
|
214
|
+
const section = carried?.map?.broker ?? {};
|
|
179
215
|
const forbidden = [section.snapshot?.path, section.checkout, env.RK_GATE_CHECKOUT, ...sources.flatMap((source) => source.dirs)].filter((p) => typeof p === "string" && p);
|
|
180
216
|
for (const place of forbidden) if (isInside(signedDir, place)) refuse("SIGN_COPY_PLACE", `${name}: the signing directory ${signedDir} is inside ${place} (the snapshot, the checkout or a broker output directory)`);
|
|
181
217
|
fs.mkdirSync(signedDir, { recursive: true, mode: 0o700 });
|