@rightkit/release 0.4.1 → 0.5.1

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.
@@ -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();
package/stage-sidecar.mjs CHANGED
@@ -23,7 +23,7 @@
23
23
  // metadata`) or { npm: "<pkg>/<file>" }. The wrapper form { sidecars, profiles: { dev, release } } sets the default cargo
24
24
  // profile pair; RIGHTKIT_BUILD_MODE (dev|release, default release) selects. The target `universal-apple-darwin` builds
25
25
  // both slices of a cargo-bin and lipo-creates `<name>-universal-apple-darwin`. `ffmpeg` and `onnxruntime` do the same from their
26
- // per-arch catalog rows (each slice staged under .right-release/slices/<triple>, then lipo-merged), a `cmake` entry builds with
26
+ // per-arch catalog rows (each slice staged under .right-release/slices/<triple>, then lipo-merged; an onnxruntime `universal` macOS row is staged once, lipo -archs checked, no merge), a `cmake` entry builds with
27
27
  // CMAKE_OSX_ARCHITECTURES="arm64;x86_64". A `prebuilt` entry does it from per-arch host-built inputs (`from: { aarch64, x86_64 }`, or the
28
28
  // staged `<name>-<slice triple>` files), each verified against its own manifest entry and then lipo-merged. An `ffmpeg` source may be a
29
29
  // local path (`sources.ffmpeg: { path: "../vendor/ffmpeg-lgpl" }`) instead of a pinned https archive.
@@ -526,7 +526,10 @@ export function defaultRun(command, args, { cwd, env = process.env } = {}) {
526
526
  * a `fileName` containing `/` selects the one member whose full archive path equals it (case-sensitive, leading `./` ignored on both
527
527
  * sides), so archives with the same base name in several directories (ORT DirectML nupkgs) can be addressed. The returned file is
528
528
  * always under `destDir`; callers copy it to their own declared output name, so the member path never decides where anything is written.
529
+ * A bare name ignores members inside a `.dSYM` bundle: Apple archives (ORT osx tgz) ship the DWARF copy under the same base name.
529
530
  */
531
+ const isDebugSymbolMember = (member) => member.split("/").some((part) => part.toLowerCase().endsWith(".dsym"));
532
+
530
533
  export function defaultExtractMember({ archive, fileName, destDir, run = defaultRun }) {
531
534
  const wanted = normalizeArchiveMember(fileName);
532
535
  if (wanted.error) fail("STAGE_SIDECAR_EXTRACT_FAILED", `archive member ${JSON.stringify(fileName)} ${wanted.error}`);
@@ -535,7 +538,7 @@ export function defaultExtractMember({ archive, fileName, destDir, run = default
535
538
  const lines = String(listing.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
536
539
  const members = wanted.byPath
537
540
  ? lines.filter((line) => (line.startsWith("./") ? line.slice(2) : line) === wanted.value)
538
- : lines.filter((line) => path.posix.basename(line) === wanted.value);
541
+ : lines.filter((line) => path.posix.basename(line) === wanted.value && !isDebugSymbolMember(line));
539
542
  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`);
540
543
  const extracted = run("tar", ["-xf", archive, "-C", destDir, members[0]], { cwd: path.dirname(archive) });
541
544
  if (extracted.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot extract ${members[0]} from ${archive}`);
@@ -912,18 +915,48 @@ async function stageFfmpeg(entry, ctx) {
912
915
  }
913
916
  }
914
917
 
918
+ /** Catalog statuses that may serve a staged library; `stale-unverified` and `planned` rows never do. */
919
+ const SERVING_STATUS = new Set(["approved", "pinned"]);
920
+
921
+ /**
922
+ * The one onnxruntime catalog row that serves target `t` (mirrors rightkit-onnx provision.rs select()): an approved or pinned
923
+ * exact-(os, arch) row wins; only when none exists does a macOS `universal` row (both Apple slices) serve an aarch64 or x86_64
924
+ * target. A stale-unverified or planned row is never selected. With nothing serving, the typed error names the exact-arch row's
925
+ * status when there is one, else the missing row.
926
+ */
927
+ function selectOnnxRuntimeRow(loaded, entry, t, where) {
928
+ const candidates = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === t.os);
929
+ const serving = candidates.filter((row) => SERVING_STATUS.has(row.status));
930
+ const exact = serving.filter((row) => row.arch === t.arch);
931
+ const appleThin = t.os === "macos" && (t.arch === "aarch64" || t.arch === "x86_64");
932
+ const hits = exact.length ? exact : appleThin ? serving.filter((row) => row.arch === "universal") : [];
933
+ if (hits.length === 1) return hits[0];
934
+ if (hits.length > 1) return fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${hits.length} ${where} rows`);
935
+ const unserved = candidates.filter((row) => row.arch === t.arch);
936
+ if (unserved.length === 0) return fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has no ${where} row`);
937
+ if (unserved.length > 1) return fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${unserved.length} ${where} rows`);
938
+ return fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${unserved[0].id} is ${unserved[0].status}, not approved or pinned`);
939
+ }
940
+
941
+ /**
942
+ * The archive member of a catalog row: archive.member when present; else, for a macOS `.zip` archive, `<archive name without
943
+ * .zip>/lib/<artifactFileName>` (as provision.rs derives it); else the bare artifactFileName.
944
+ */
945
+ function onnxRuntimeMember(row) {
946
+ if (row.archive.member !== undefined) return row.archive.member;
947
+ const archiveName = path.posix.basename(new URL(row.archive.url).pathname);
948
+ if (row.os === "macos" && archiveName.endsWith(".zip")) return `${archiveName.slice(0, -".zip".length)}/lib/${row.artifactFileName}`;
949
+ return row.artifactFileName;
950
+ }
951
+
915
952
  /**
916
953
  * Stage one architecture of onnxruntime: the approved or pinned catalog row for `t`, its archive (digest-checked), the library
917
954
  * (digest-checked) into `destDir` (default the entry's resourceDir), and the notices. Returns { row, dest, outName, notices }.
918
955
  */
919
956
  async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
920
957
  const { appRoot, fs } = ctx;
921
- const rows = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === t.os && row.arch === t.arch);
922
958
  const where = `onnxruntime ${entry.version} ${t.os}/${t.arch}`;
923
- if (rows.length === 0) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has no ${where} row`);
924
- if (rows.length > 1) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${rows.length} ${where} rows`);
925
- const [row] = rows;
926
- if (!["approved", "pinned"].includes(row.status)) fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} is ${row.status}, not approved or pinned`);
959
+ const row = selectOnnxRuntimeRow(loaded, entry, t, where);
927
960
  for (const key of ["sha256", "artifactFileName"]) if (typeof row[key] !== "string" || !row[key]) fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} has no ${key}`);
928
961
  if (!SHA256.test(row.sha256) || !SHA256.test(row.archive?.sha256 ?? "") || typeof row.archive?.url !== "string") fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} lacks a valid library digest or archive url and digest`);
929
962
  if (!Array.isArray(row.noticeFiles) || !row.noticeFiles.length) fail("STAGE_SIDECAR_NOTICE_MISSING", `the ${where} row ${row.id} lists no notice files; the licence must ship with the library`);
@@ -936,7 +969,7 @@ async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
936
969
  for (const { source } of notices) if (!fs.existsSync(source)) fail("STAGE_SIDECAR_NOTICE_MISSING", `notice file not found: ${source}`);
937
970
 
938
971
  // The archive member is the catalog's archive.member (an archive path) when present, else the library file name; the output name never comes from it.
939
- const member = row.archive.member ?? row.artifactFileName;
972
+ const member = onnxRuntimeMember(row);
940
973
  const memberCheck = normalizeArchiveMember(member);
941
974
  if (memberCheck.error) fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} has an invalid archive member ${JSON.stringify(member)}: ${memberCheck.error}`);
942
975
  const outName = entry.as ?? row.artifactFileName;
@@ -978,7 +1011,19 @@ async function stageOnnxRuntime(entry, ctx) {
978
1011
  const { row, dest, outName, notices } = await stageOnnxRuntimeSlice(entry, ctx, loaded, target);
979
1012
  return [{ kind: "onnxruntime", name: outName, path: rel(ctx, dest), sha256: row.sha256, sizeBytes: fs.statSync(dest).size, changed: null, catalogRow: row.id, version: row.version, archiveSha256: row.archive.sha256, license: row.licenseSpdx ?? null, notices, externalBin: false }];
980
1013
  }
981
- // universal-apple-darwin: each arch row is staged thin under SLICE_ROOT, then merged into the entry's resource file.
1014
+ // universal-apple-darwin: one macOS `universal` row serving both slices is staged once, in place, with no lipo merge.
1015
+ const selected = SLICES.map((triple) => selectOnnxRuntimeRow(loaded, entry, parseTargetTriple(triple), `onnxruntime ${entry.version} macos/${triple.split("-")[0]}`));
1016
+ if (selected.some((row) => row.arch === "universal")) {
1017
+ if (selected[0] !== selected[1]) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog mixes a universal row with exact-arch rows (${selected.map((row) => row.id).join(", ")}); universal-apple-darwin takes either one universal row or one row per arch`);
1018
+ const staged = await stageOnnxRuntimeSlice(entry, ctx, loaded, parseTargetTriple(SLICES[0]));
1019
+ requireUniversalFile(ctx, staged.dest, `onnxruntime ${entry.version} ${staged.row.id}`);
1020
+ return [{
1021
+ kind: "onnxruntime", name: staged.outName, path: rel(ctx, staged.dest), sha256: staged.row.sha256, sizeBytes: fs.statSync(staged.dest).size, changed: null, sourceSha256: staged.row.sha256,
1022
+ catalogRow: staged.row.id, version: entry.version, archiveSha256: staged.row.archive.sha256, license: staged.row.licenseSpdx ?? null, notices: staged.notices, externalBin: false,
1023
+ slices: SLICES.map((triple) => ({ triple, sha256: staged.row.sha256, catalogRow: staged.row.id, archiveSha256: staged.row.archive.sha256 })),
1024
+ }];
1025
+ }
1026
+ // Otherwise each arch row is staged thin under SLICE_ROOT, then merged into the entry's resource file.
982
1027
  const slices = [];
983
1028
  for (const triple of SLICES) slices.push({ triple, ...(await stageOnnxRuntimeSlice(entry, ctx, loaded, parseTargetTriple(triple), { destDir: `${SLICE_ROOT}/${triple}` })) });
984
1029
  const names = [...new Set(slices.map((slice) => slice.outName))];
@@ -0,0 +1,177 @@
1
+ // A strict parser for the YAML subset GitHub Actions workflows in this repository use: block
2
+ // mappings, block sequences, literal block scalars (`|` and `|-`, no indentation indicator), flow
3
+ // sequences of scalars on one line, quoted and plain single-line scalars, comments. It parses
4
+ // structure so a contract test can assert on it. It REJECTS everything it does not implement exactly
5
+ // instead of guessing, so an unparsed construct can never pass as "absent" and no scalar can mean
6
+ // something different to GitHub than to the contract: tabs anywhere, folded scalars (`>`) in any
7
+ // form, block scalars with an indentation indicator or `+` chomping, anchors, aliases, tags, merge
8
+ // keys, explicit keys, document markers, flow mappings (except the empty `{}`), flow collections that span lines, multi-line
9
+ // scalars, duplicate keys. Every scalar is returned as a string. Literal block scalars follow the
10
+ // spec: `|` keeps one trailing newline, `|-` none.
11
+
12
+ function fail(no, message) { throw new Error(`yaml line ${no}: ${message}`); }
13
+
14
+ function stripComment(text) {
15
+ let quote = null;
16
+ for (let i = 0; i < text.length; i += 1) {
17
+ const c = text[i];
18
+ if (quote) { if (c === quote) quote = null; continue; }
19
+ if ((c === '"' || c === "'") && (i === 0 || /[\s[,:]/.test(text[i - 1]))) { quote = c; continue; }
20
+ if (c === "#" && (i === 0 || /\s/.test(text[i - 1]))) return text.slice(0, i).trimEnd();
21
+ }
22
+ return text.trimEnd();
23
+ }
24
+
25
+ function scalar(raw, no) {
26
+ const text = raw.trim();
27
+ if (text === "") return "";
28
+ if (/^[&*!]/.test(text)) fail(no, `anchors, aliases and tags are not supported: ${text.slice(0, 40)}`);
29
+ if (/^[|>]/.test(text)) fail(no, `block scalar header ${JSON.stringify(text.slice(0, 12))} is not supported here (only "|" and "|-" after a mapping key)`);
30
+ if (/^(?:[-?:](?:\s|$)|[%@`])/.test(text)) fail(no, `YAML indicator at the start of a plain scalar is not supported: ${text.slice(0, 40)}`);
31
+ // The empty flow mapping alone (`permissions: {}`) is unambiguous; any other flow mapping is refused.
32
+ if (text === "{}") return {};
33
+ if (text.startsWith("{")) fail(no, "flow mappings are not supported");
34
+ if (text.startsWith('"')) {
35
+ if (!/^"(?:[^"\\]|\\.)*"$/.test(text)) fail(no, `unterminated or trailing text after a quoted scalar: ${text.slice(0, 40)}`);
36
+ return JSON.parse(text);
37
+ }
38
+ if (text.startsWith("'")) {
39
+ if (!/^'(?:[^']|'')*'$/.test(text)) fail(no, `unterminated or trailing text after a quoted scalar: ${text.slice(0, 40)}`);
40
+ return text.slice(1, -1).replaceAll("''", "'");
41
+ }
42
+ if (text.startsWith("[")) {
43
+ if (!text.endsWith("]")) fail(no, "flow sequence must close on the same line");
44
+ const inner = text.slice(1, -1);
45
+ const items = []; let current = ""; let quote = null;
46
+ for (const c of inner) {
47
+ if (quote) { current += c; if (c === quote) quote = null; continue; }
48
+ if (c === '"' || c === "'") { quote = c; current += c; continue; }
49
+ if (c === "[" || c === "{") fail(no, "nested flow collections are not supported");
50
+ if (c === ",") { items.push(current); current = ""; continue; }
51
+ current += c;
52
+ }
53
+ if (quote) fail(no, "unterminated quote in flow sequence");
54
+ items.push(current);
55
+ const kept = items.map((item) => item.trim());
56
+ while (kept.length && kept.at(-1) === "") kept.pop(); // a trailing comma
57
+ if (kept.includes("")) fail(no, "empty item in a flow sequence");
58
+ return kept.map((item) => scalar(item, no));
59
+ }
60
+ return text;
61
+ }
62
+
63
+ export function parseYaml(source) {
64
+ const raw = String(source).replace(/\r\n/g, "\n").split("\n");
65
+ raw.forEach((line, i) => { if (line.includes("\t")) fail(i + 1, "tabs are not allowed"); });
66
+ const lines = raw.map((text, i) => ({ no: i + 1, raw: text, indent: /^( *)/.exec(text)[1].length, body: stripComment(text).trim() }));
67
+ let cursor = 0;
68
+
69
+ const skipBlank = () => { while (cursor < lines.length && lines[cursor].body === "") cursor += 1; };
70
+
71
+ function blockScalar(parentIndent, header, no) {
72
+ const strip = header === "|-";
73
+ const body = [];
74
+ let blockIndent = null;
75
+ while (cursor < lines.length) {
76
+ const line = lines[cursor];
77
+ if (line.raw.trim() === "") {
78
+ // A blank line deeper than the block indentation carries content (its spaces); not modeled.
79
+ if (blockIndent !== null && line.raw.length > blockIndent) fail(line.no, "whitespace-only line inside a block scalar is indented past the block; not supported");
80
+ body.push(""); cursor += 1; continue;
81
+ }
82
+ if (line.indent <= parentIndent) break;
83
+ if (blockIndent === null) blockIndent = line.indent;
84
+ if (line.indent < blockIndent) fail(line.no, "block scalar line is indented less than its first line");
85
+ body.push(line.raw.slice(blockIndent));
86
+ cursor += 1;
87
+ }
88
+ while (body.length && body.at(-1) === "") body.pop();
89
+ if (body.length === 0) return "";
90
+ return body.join("\n") + (strip ? "" : "\n");
91
+ }
92
+
93
+ function parseBlock(indent) {
94
+ skipBlank();
95
+ if (cursor >= lines.length) return null;
96
+ const first = lines[cursor];
97
+ if (first.indent !== indent) fail(first.no, `expected indent ${indent}, found ${first.indent}`);
98
+ return /^-(\s|$)/.test(first.body) ? parseSequence(indent) : parseMapping(indent);
99
+ }
100
+
101
+ function parseSequence(indent) {
102
+ const out = [];
103
+ for (;;) {
104
+ skipBlank();
105
+ if (cursor >= lines.length) break;
106
+ const line = lines[cursor];
107
+ if (line.indent < indent) break;
108
+ if (line.indent > indent) fail(line.no, "unexpected indentation inside a sequence");
109
+ if (!/^-(\s|$)/.test(line.body)) break;
110
+ const rest = line.body.slice(1);
111
+ const offset = rest.length - rest.trimStart().length;
112
+ const content = rest.trimStart();
113
+ if (content === "") {
114
+ cursor += 1; skipBlank();
115
+ const next = lines[cursor];
116
+ if (!next || next.indent <= indent) { out.push(""); continue; }
117
+ out.push(parseBlock(next.indent));
118
+ continue;
119
+ }
120
+ // "- key: value" opens a mapping whose keys sit at the column after "- ".
121
+ if (keyMatch(content) && !/^["'[]/.test(content)) {
122
+ const childIndent = indent + 1 + offset;
123
+ lines[cursor] = { ...line, indent: childIndent, body: content, raw: " ".repeat(childIndent) + content };
124
+ out.push(parseMapping(childIndent));
125
+ continue;
126
+ }
127
+ cursor += 1;
128
+ out.push(scalar(content, line.no));
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function keyMatch(body) {
134
+ const m = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s:#"'[\]{},&*!|>-][^:]*?|-[^\s:][^:]*?)\s*:(?:\s+(.*))?$/.exec(body);
135
+ return m ? { key: m[1], value: m[2] ?? "" } : null;
136
+ }
137
+
138
+ function parseMapping(indent) {
139
+ const out = {};
140
+ for (;;) {
141
+ skipBlank();
142
+ if (cursor >= lines.length) break;
143
+ const line = lines[cursor];
144
+ if (line.indent < indent) break;
145
+ if (line.indent > indent) fail(line.no, "unexpected indentation inside a mapping");
146
+ if (/^-(\s|$)/.test(line.body)) break;
147
+ const hit = keyMatch(line.body);
148
+ if (!hit) fail(line.no, `cannot parse mapping entry: ${line.body.slice(0, 60)}`);
149
+ const key = hit.key.startsWith('"') || hit.key.startsWith("'") ? scalar(hit.key, line.no) : hit.key;
150
+ if (Object.hasOwn(out, key)) fail(line.no, `duplicate key ${JSON.stringify(key)}`);
151
+ cursor += 1;
152
+ const value = hit.value.trim();
153
+ if (value === "") {
154
+ skipBlank();
155
+ const next = lines[cursor];
156
+ if (next && next.indent > indent) out[key] = parseBlock(next.indent);
157
+ else if (next && next.indent === indent && /^-(\s|$)/.test(next.body)) out[key] = parseSequence(indent);
158
+ else out[key] = "";
159
+ } else if (value === "|" || value === "|-") {
160
+ out[key] = blockScalar(indent, value, line.no);
161
+ } else if (/^[|>]/.test(value)) {
162
+ fail(line.no, `block scalar header ${JSON.stringify(value.slice(0, 12))} is not supported (indentation indicators, "+" chomping and folded scalars are rejected)`);
163
+ } else {
164
+ const next = (skipBlank(), lines[cursor]);
165
+ if (next && next.indent > indent) fail(next.no, "multi-line plain scalars are not supported");
166
+ out[key] = scalar(value, line.no);
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ skipBlank();
173
+ const result = cursor >= lines.length ? {} : parseBlock(lines[cursor].indent);
174
+ skipBlank();
175
+ if (cursor < lines.length) fail(lines[cursor].no, "unparsed trailing content");
176
+ return result;
177
+ }