@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
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.
|
|
@@ -73,6 +73,28 @@ const MODES = new Set(["dev", "release"]);
|
|
|
73
73
|
const PROFILE = /^[a-z][a-z0-9_-]*$/;
|
|
74
74
|
const PLATFORMS = new Set(["mac", "win", "linux"]);
|
|
75
75
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
76
|
+
/** One path segment of an archive member path: no glob metacharacters, no separators, no drive colon. */
|
|
77
|
+
const MEMBER_SEGMENT = /^[A-Za-z0-9_.+@~-]+$/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Normalize and guard a requested archive member. A bare file name (no `/`) selects the one member with that base name; a path
|
|
81
|
+
* (contains `/`) selects the one member with exactly that archive path. A leading `./` is stripped. Absolute paths, drive
|
|
82
|
+
* prefixes, backslashes, empty, `.` and `..` segments and glob metacharacters are refused. Returns { value, byPath } or
|
|
83
|
+
* { error }. The member never influences where the extracted file is written.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeArchiveMember(member) {
|
|
86
|
+
if (typeof member !== "string" || !member) return { error: "must be a non-empty string" };
|
|
87
|
+
if (member.includes("\\")) return { error: "must not contain a backslash" };
|
|
88
|
+
const value = member.startsWith("./") ? member.slice(2) : member;
|
|
89
|
+
if (value.startsWith("/")) return { error: "must not be an absolute path" };
|
|
90
|
+
if (/^[A-Za-z]:/.test(value)) return { error: "must not start with a drive prefix" };
|
|
91
|
+
const segments = value.split("/");
|
|
92
|
+
if (segments.some((segment) => segment === "")) return { error: "must not contain an empty segment" };
|
|
93
|
+
if (segments.some((segment) => segment === "." || segment === "..")) return { error: "must not contain a '.' or '..' segment" };
|
|
94
|
+
// A bare file name keeps today's matching (any characters but the refused ones above); a path is held to a strict segment alphabet so it can never act as a tar glob.
|
|
95
|
+
if (segments.length > 1 && !segments.every((segment) => MEMBER_SEGMENT.test(segment))) return { error: "has a segment with characters outside A-Z a-z 0-9 _ . + @ ~ -" };
|
|
96
|
+
return { value, byPath: segments.length > 1 };
|
|
97
|
+
}
|
|
76
98
|
const KEYS = Object.freeze({
|
|
77
99
|
"cargo-bin": ["kind", "package", "bin", "name", "profile", "features", "manifestPath", "sha256", "dest", "clean", "platforms"],
|
|
78
100
|
ffmpeg: ["kind", "variant", "variants", "tools", "notices", "catalog", "stage", "bundleDir", "sources", "probe", "platforms"],
|
|
@@ -390,7 +412,7 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
|
|
|
390
412
|
if (item.dest === undefined && item.to === undefined) errors.push(`${where}: dest (or to) is required`);
|
|
391
413
|
const dest = destOf(item.dest ?? item.to, item.dest === undefined ? "to" : "dest");
|
|
392
414
|
if (item.size !== undefined && (!Number.isSafeInteger(item.size) || item.size <= 0)) errors.push(`${where}: size must be a positive integer (bytes)`);
|
|
393
|
-
if (item.extract !== undefined
|
|
415
|
+
if (item.extract !== undefined) { const member = normalizeArchiveMember(item.extract); if (member.error || (!member.byPath && !NAME.test(member.value))) errors.push(`${where}: extract must be the bare file name of one archive member or its archive path (posix, no leading /, no .. segment): ${member.error ?? "invalid file name"}`); }
|
|
394
416
|
if (item.memberSha256 !== undefined && (item.extract === undefined || !SHA256.test(item.memberSha256 ?? ""))) errors.push(`${where}: memberSha256 must be a lowercase 64-hex SHA-256 and needs extract`);
|
|
395
417
|
if (item.executable !== undefined && typeof item.executable !== "boolean") errors.push(`${where}: executable must be a boolean`);
|
|
396
418
|
const clean = cleanOf();
|
|
@@ -499,15 +521,31 @@ export function defaultRun(command, args, { cwd, env = process.env } = {}) {
|
|
|
499
521
|
});
|
|
500
522
|
}
|
|
501
523
|
|
|
502
|
-
/**
|
|
524
|
+
/**
|
|
525
|
+
* Default archive member extraction (bsdtar reads tgz and zip/nupkg). A bare `fileName` selects the one member whose basename it is;
|
|
526
|
+
* a `fileName` containing `/` selects the one member whose full archive path equals it (case-sensitive, leading `./` ignored on both
|
|
527
|
+
* sides), so archives with the same base name in several directories (ORT DirectML nupkgs) can be addressed. The returned file is
|
|
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.
|
|
530
|
+
*/
|
|
531
|
+
const isDebugSymbolMember = (member) => member.split("/").some((part) => part.toLowerCase().endsWith(".dsym"));
|
|
532
|
+
|
|
503
533
|
export function defaultExtractMember({ archive, fileName, destDir, run = defaultRun }) {
|
|
534
|
+
const wanted = normalizeArchiveMember(fileName);
|
|
535
|
+
if (wanted.error) fail("STAGE_SIDECAR_EXTRACT_FAILED", `archive member ${JSON.stringify(fileName)} ${wanted.error}`);
|
|
504
536
|
const listing = run("tar", ["-tf", archive], { cwd: path.dirname(archive) });
|
|
505
537
|
if (listing.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot list ${archive}`);
|
|
506
|
-
const
|
|
507
|
-
|
|
538
|
+
const lines = String(listing.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
539
|
+
const members = wanted.byPath
|
|
540
|
+
? lines.filter((line) => (line.startsWith("./") ? line.slice(2) : line) === wanted.value)
|
|
541
|
+
: lines.filter((line) => path.posix.basename(line) === wanted.value && !isDebugSymbolMember(line));
|
|
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`);
|
|
508
543
|
const extracted = run("tar", ["-xf", archive, "-C", destDir, members[0]], { cwd: path.dirname(archive) });
|
|
509
544
|
if (extracted.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot extract ${members[0]} from ${archive}`);
|
|
510
|
-
|
|
545
|
+
const out = path.join(destDir, ...members[0].split("/"));
|
|
546
|
+
const inside = path.relative(path.resolve(destDir), path.resolve(out));
|
|
547
|
+
if (!inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) fail("STAGE_SIDECAR_EXTRACT_FAILED", `member ${members[0]} of ${archive} resolves outside the extraction directory`);
|
|
548
|
+
return out;
|
|
511
549
|
}
|
|
512
550
|
|
|
513
551
|
async function defaultFetchBytes(url) {
|
|
@@ -877,18 +915,48 @@ async function stageFfmpeg(entry, ctx) {
|
|
|
877
915
|
}
|
|
878
916
|
}
|
|
879
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
|
+
|
|
880
952
|
/**
|
|
881
953
|
* Stage one architecture of onnxruntime: the approved or pinned catalog row for `t`, its archive (digest-checked), the library
|
|
882
954
|
* (digest-checked) into `destDir` (default the entry's resourceDir), and the notices. Returns { row, dest, outName, notices }.
|
|
883
955
|
*/
|
|
884
956
|
async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
|
|
885
957
|
const { appRoot, fs } = ctx;
|
|
886
|
-
const rows = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === t.os && row.arch === t.arch);
|
|
887
958
|
const where = `onnxruntime ${entry.version} ${t.os}/${t.arch}`;
|
|
888
|
-
|
|
889
|
-
if (rows.length > 1) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${rows.length} ${where} rows`);
|
|
890
|
-
const [row] = rows;
|
|
891
|
-
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);
|
|
892
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}`);
|
|
893
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`);
|
|
894
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`);
|
|
@@ -900,6 +968,10 @@ async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
|
|
|
900
968
|
});
|
|
901
969
|
for (const { source } of notices) if (!fs.existsSync(source)) fail("STAGE_SIDECAR_NOTICE_MISSING", `notice file not found: ${source}`);
|
|
902
970
|
|
|
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.
|
|
972
|
+
const member = onnxRuntimeMember(row);
|
|
973
|
+
const memberCheck = normalizeArchiveMember(member);
|
|
974
|
+
if (memberCheck.error) fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} has an invalid archive member ${JSON.stringify(member)}: ${memberCheck.error}`);
|
|
903
975
|
const outName = entry.as ?? row.artifactFileName;
|
|
904
976
|
const dest = path.resolve(appRoot, destDir ?? entry.resourceDir, outName);
|
|
905
977
|
if (fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === row.sha256) {
|
|
@@ -915,9 +987,9 @@ async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
|
|
|
915
987
|
}
|
|
916
988
|
const scratch = ctx.makeTempDir();
|
|
917
989
|
try {
|
|
918
|
-
const library = await ctx.extractMember({ archive, fileName:
|
|
990
|
+
const library = await ctx.extractMember({ archive, fileName: member, destDir: scratch });
|
|
919
991
|
const actual = sha256Of(fs.readFileSync(library));
|
|
920
|
-
if (actual !== row.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${
|
|
992
|
+
if (actual !== row.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${member} extracted as ${actual}, the catalog pins ${row.sha256}; nothing staged`, { expected: row.sha256, actual });
|
|
921
993
|
copyInto(ctx, library, dest, { executable: false });
|
|
922
994
|
} finally {
|
|
923
995
|
ctx.removeDir(scratch);
|
|
@@ -939,7 +1011,19 @@ async function stageOnnxRuntime(entry, ctx) {
|
|
|
939
1011
|
const { row, dest, outName, notices } = await stageOnnxRuntimeSlice(entry, ctx, loaded, target);
|
|
940
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 }];
|
|
941
1013
|
}
|
|
942
|
-
// universal-apple-darwin:
|
|
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.
|
|
943
1027
|
const slices = [];
|
|
944
1028
|
for (const triple of SLICES) slices.push({ triple, ...(await stageOnnxRuntimeSlice(entry, ctx, loaded, parseTargetTriple(triple), { destDir: `${SLICE_ROOT}/${triple}` })) });
|
|
945
1029
|
const names = [...new Set(slices.map((slice) => slice.outName))];
|
|
@@ -1042,7 +1126,7 @@ async function ensureCachedDownload(ctx, { url, sha256, size }) {
|
|
|
1042
1126
|
|
|
1043
1127
|
/**
|
|
1044
1128
|
* A pinned https download. `dest` is copied only from the verified cache entry. With `extract` the download is an archive
|
|
1045
|
-
* (zip, tgz): the one member whose base name is `extract` is unpacked (digest-checked against `memberSha256` when given) and
|
|
1129
|
+
* (zip, tgz): the one member whose base name is `extract` (or, when `extract` contains `/`, whose archive path equals it) is unpacked (digest-checked against `memberSha256` when given) and
|
|
1046
1130
|
* only that member is staged, so no `command` entry or shell is needed to unpack it.
|
|
1047
1131
|
*/
|
|
1048
1132
|
async function stageRemote(entry, ctx) {
|
package/swap-helper-bundle.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// The Developer-ID-signed `bin/darwin/rightkit-swap` that @rightkit/release ships (Orthic-Labs/rightkit#258).
|
|
2
|
-
// rightkit-swap is the bin target of crates/rightkit-fs (renamex_np RENAME_SWAP).
|
|
3
|
-
//
|
|
4
|
-
// `
|
|
2
|
+
// rightkit-swap is the bin target of crates/rightkit-fs (renamex_np RENAME_SWAP). Two signing routes produce it:
|
|
3
|
+
// (a) local publish — `right-release publish npm --local` on the Mac calls `prepareSwapHelper` before packing;
|
|
4
|
+
// (b) CI qualification — `publish.yml` build-swap -> sign-swap -> pack (docs/CI.md), where sign-swap imports the
|
|
5
|
+
// Developer ID from the `release` environment secrets. Route (b) stays blocked until root provisions
|
|
6
|
+
// APPLE_CERTIFICATE_BASE64/APPLE_CERTIFICATE_PASSWORD there; exporting that identity needs an Aqua login session
|
|
7
|
+
// (SSH export fails errSecInteractionNotAllowed/-25308 & the signing agent has no key-export capability — never
|
|
8
|
+
// retry either). install-app calls `assertShippedSwapHelperSigned` before it trusts the helper.
|
|
5
9
|
import crypto from "node:crypto";
|
|
6
10
|
import nodeFs from "node:fs";
|
|
7
11
|
import path from "node:path";
|
|
@@ -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
|
+
}
|