fix(release): bound plugin artifact downloads

This commit is contained in:
Vincent Koc
2026-08-20 22:41:26 -07:00
parent 18152738ce
commit a9d0e6d230
8 changed files with 382 additions and 13 deletions
+4 -3
View File
@@ -343,9 +343,10 @@ all-plugin runtime time on release-only product coverage.
`Plugin Prerelease` performs a supplemental scan of checked-in npm package input
as inert data; it never runs candidate lifecycle, asset, build, install, or
replacement scanner code. This scan does not approve post-build or publication
bytes. The publication workflow must independently scan its exact final artifact
and publish the same digest. Ingestion stays fail-slow so one malformed package
cannot hide other package reports.
bytes. A future publisher redesign must scan the exact final bytes and publish
that identical digest before this can become a publication gate; the current
publisher does not provide that guarantee. Ingestion stays fail-slow so one
malformed package cannot hide other package reports.
Use one operator, one transition-only watcher, and at most one investigator for
the current failed surface. Parent timeout or cancellation leaves adopted exact
+50 -2
View File
@@ -426,12 +426,60 @@ jobs:
- name: Install trusted scanner dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Download supplemental inert plugin inputs
- name: Bound supplemental inert plugin input downloads
id: artifact-download-plan
continue-on-error: true
env:
CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }}
EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
metadata="$RUNNER_TEMP/plugin-npm-security-artifact-api.json"
plan="$RUNNER_TEMP/plugin-npm-security-artifact-download-plan.json"
gh api --paginate --slurp \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2026-03-10" \
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100&direction=asc" \
> "$metadata"
node --import tsx scripts/plugin-npm-security-artifact-plan.mts \
--artifact-metadata-json "$metadata" \
--candidate-sha "$CANDIDATE_SHA" \
--expected-packages-json "$EXPECTED_PACKAGES_JSON" \
--output "$plan"
artifact_ids="$(jq -r '[.artifacts[].id | tostring] | join(",")' "$plan")"
echo "artifact_ids=$artifact_ids" >> "$GITHUB_OUTPUT"
# The pinned action validates server digests and downloads ID-bound artifacts
# in batches of five only after the trusted metadata plan enforces byte bounds.
- name: Download bounded supplemental inert plugin inputs
id: download-bounded-artifacts
if: steps.artifact-download-plan.outcome == 'success' && steps.artifact-download-plan.outputs.artifact_ids != ''
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: plugin-npm-security-package-${{ needs.resolve-candidate.outputs.checkout_revision }}-*
artifact-ids: ${{ steps.artifact-download-plan.outputs.artifact_ids }}
digest-mismatch: error
github-token: ${{ github.token }}
path: ${{ runner.temp }}/plugin-npm-security-packages
repository: ${{ github.repository }}
run-id: ${{ github.run_id }}
- name: Normalize single supplemental inert plugin input
if: steps.download-bounded-artifacts.outcome == 'success'
env:
ARTIFACT_ROOT: ${{ runner.temp }}/plugin-npm-security-packages
PLAN_PATH: ${{ runner.temp }}/plugin-npm-security-artifact-download-plan.json
shell: bash
run: |
set -euo pipefail
[[ "$(jq '.artifacts | length' "$PLAN_PATH")" == "1" ]] || exit 0
artifact_name="$(jq -r '.artifacts[0].name' "$PLAN_PATH")"
staging="$RUNNER_TEMP/plugin-npm-security-single-artifact"
mkdir -p "$staging" "$ARTIFACT_ROOT/$artifact_name"
find "$ARTIFACT_ROOT" -mindepth 1 -maxdepth 1 -type f -exec mv {} "$staging/" \;
find "$staging" -mindepth 1 -maxdepth 1 -type f -exec mv {} "$ARTIFACT_ROOT/$artifact_name/" \;
- name: Scan supplemental inert plugin inputs
env:
+1
View File
@@ -78,6 +78,7 @@ const repositoryScriptEntries = [
"scripts/openclaw-release-clawhub-plan.ts!",
"scripts/openclaw-release-clawhub-runtime-state.ts!",
// Plugin Prerelease builds immutable package artifacts, then scans them in a bounded child.
"scripts/plugin-npm-security-artifact-plan.mts!",
"scripts/plugin-npm-security-prepare.mts!",
"scripts/plugin-npm-security-scan-runner.mjs!",
"scripts/plugin-npm-security-scan.mts!",
+104 -3
View File
@@ -12,6 +12,7 @@ import {
import { tmpdir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { promisify } from "node:util";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
isScannable,
scanDirectoryWithSummary,
@@ -81,6 +82,8 @@ export const MAX_PLUGIN_PACKAGE_MANIFEST_BYTES = 256 * 1024;
export const MAX_PLUGIN_SCAN_FINDINGS_PER_PACKAGE = 10_000;
export const MAX_PLUGIN_SCAN_TOTAL_FINDINGS = 50_000;
export const MAX_PLUGIN_SCAN_REPORT_BYTES = 1024 * 1024;
export const MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES = 128 * 1024 * 1024;
export const MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES = 512 * 1024 * 1024;
const MAX_PLUGIN_SECURITY_ARTIFACT_METADATA_BYTES = 64 * 1024;
const MAX_PLUGIN_TARBALL_BYTES = 128 * 1024 * 1024;
const MAX_PLUGIN_TARBALL_TOTAL_BYTES = 512 * 1024 * 1024;
@@ -536,6 +539,104 @@ function artifactDirectoryName(candidateSha: string, extensionId: string): strin
return `${PLUGIN_SECURITY_ARTIFACT_PREFIX}${candidateSha}-${extensionId}`;
}
export type PluginNpmSecurityArtifactDownloadPlan = {
artifacts: Array<{
digest: string;
id: number;
name: string;
sizeInBytes: number;
}>;
expectedArtifactCount: number;
totalBytes: number;
};
export function planPluginNpmSecurityArtifactDownloads(params: {
artifactPages: unknown;
candidateSha: string;
expectedPackages: unknown;
}): PluginNpmSecurityArtifactDownloadPlan {
if (!/^[0-9a-f]{40}$/u.test(params.candidateSha)) {
throw new Error("Plugin security artifact download candidate identity is invalid.");
}
const expectedPackages = parseExpectedPackages(params.expectedPackages);
const expectedNames = new Set(
expectedPackages.map((plugin) =>
artifactDirectoryName(params.candidateSha, plugin.extensionId),
),
);
const expectedPrefix = `${PLUGIN_SECURITY_ARTIFACT_PREFIX}${params.candidateSha}-`;
if (
!Array.isArray(params.artifactPages) ||
params.artifactPages.length === 0 ||
params.artifactPages.length > 100
) {
throw new Error("Plugin security artifact metadata pages are invalid.");
}
const artifacts: PluginNpmSecurityArtifactDownloadPlan["artifacts"] = [];
const seenIds = new Set<number>();
const seenNames = new Set<string>();
let totalBytes = 0;
for (const page of params.artifactPages) {
if (!isRecord(page) || !Array.isArray(page.artifacts) || page.artifacts.length > 100) {
throw new Error("Plugin security artifact metadata page is invalid.");
}
for (const entry of page.artifacts) {
if (!isRecord(entry) || typeof entry.name !== "string") {
throw new Error("Plugin security artifact metadata entry is invalid.");
}
if (!entry.name.startsWith(expectedPrefix)) {
continue;
}
if (!expectedNames.has(entry.name)) {
throw new Error("Plugin security artifact metadata contains an unexpected package.");
}
if (
!Number.isSafeInteger(entry.id) ||
(entry.id as number) <= 0 ||
!Number.isSafeInteger(entry.size_in_bytes) ||
(entry.size_in_bytes as number) <= 0 ||
entry.expired !== false ||
typeof entry.digest !== "string" ||
!/^sha256:[0-9a-f]{64}$/u.test(entry.digest)
) {
throw new Error("Plugin security artifact metadata entry is invalid.");
}
const id = entry.id as number;
const sizeInBytes = entry.size_in_bytes as number;
if (seenIds.has(id) || seenNames.has(entry.name)) {
throw new Error("Plugin security artifact metadata contains a duplicate package.");
}
if (sizeInBytes > MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES) {
throw new Error("Plugin security artifact exceeds the pre-download byte limit.");
}
totalBytes += sizeInBytes;
if (
!Number.isSafeInteger(totalBytes) ||
totalBytes > MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES
) {
throw new Error("Plugin security artifacts exceed the aggregate pre-download byte limit.");
}
seenIds.add(id);
seenNames.add(entry.name);
artifacts.push({
digest: entry.digest,
id,
name: entry.name,
sizeInBytes,
});
}
}
if (artifacts.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES) {
throw new Error("Plugin security artifact metadata exceeds the package-count limit.");
}
return {
artifacts: artifacts.toSorted((left, right) => compareCodeUnits(left.name, right.name)),
expectedArtifactCount: expectedPackages.length,
totalBytes,
};
}
function sanitizeArtifactIngestionError(
expectedPackage: PublishablePluginPackage,
error: unknown,
@@ -754,7 +855,7 @@ export function assertCompleteScannerSummary(
}
}
async function scanPublishablePluginArtifact(
async function scanSupplementalInertPluginInput(
plugin: PluginNpmSecurityArtifact,
): Promise<ScanPackageResult> {
const reviewedCriticalFindings: string[] = [];
@@ -768,7 +869,7 @@ async function scanPublishablePluginArtifact(
staged.inspection.packageManifest.version !== plugin.packageVersion ||
staged.inspection.tarballSha256 !== plugin.tarballSha256
) {
throw new Error(`${plugin.packageName}: publication artifact identity mismatch.`);
throw new Error(`${plugin.packageName}: supplemental inert package input identity mismatch.`);
}
for (const packedFile of staged.packedFiles) {
expectedReviewedCriticalFindings.push(
@@ -970,7 +1071,7 @@ export async function scanPublishablePluginPackages(
plugin ? sanitizePackageScanError(plugin, error) : "Unknown package: package scan failed.",
);
},
tasks: packages.map((plugin) => () => scanPublishablePluginArtifact(plugin)),
tasks: packages.map((plugin) => () => scanSupplementalInertPluginInput(plugin)),
});
return {
packageResults: results.filter((result): result is ScanPackageResult => result !== undefined),
@@ -0,0 +1,87 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
planPluginNpmSecurityArtifactDownloads,
type PluginNpmSecurityArtifactDownloadPlan,
} from "./lib/plugin-npm-security-scan.mts";
import { readBoundedRegularFile } from "./plugin-publication-artifact.mjs";
const MAX_ARTIFACT_METADATA_JSON_BYTES = 4 * 1024 * 1024;
const MAX_EXPECTED_PACKAGES_JSON_BYTES = 256 * 1024;
type ParsedArgs = {
artifactMetadataPath: string;
candidateSha: string;
expectedPackages: unknown;
outputPath: string;
};
function parseArgs(argv: string[]): ParsedArgs {
const values = new Map<string, string>();
for (let index = 0; index < argv.length; index += 2) {
const name = argv[index];
const value = argv[index + 1];
if (!name?.startsWith("--") || value === undefined || values.has(name)) {
throw new Error(`Invalid plugin security artifact plan argument near ${name}.`);
}
values.set(name, value);
}
const artifactMetadataPath = values.get("--artifact-metadata-json") ?? "";
const candidateSha = values.get("--candidate-sha") ?? "";
const expectedPackagesJson = values.get("--expected-packages-json") ?? "";
const outputPath = values.get("--output") ?? "";
if (
!artifactMetadataPath ||
!/^[0-9a-f]{40}$/u.test(candidateSha) ||
!expectedPackagesJson ||
Buffer.byteLength(expectedPackagesJson, "utf8") > MAX_EXPECTED_PACKAGES_JSON_BYTES ||
!outputPath
) {
throw new Error("Plugin security artifact plan received an invalid identity or path.");
}
return {
artifactMetadataPath: resolve(artifactMetadataPath),
candidateSha,
expectedPackages: JSON.parse(expectedPackagesJson) as unknown,
outputPath: resolve(outputPath),
};
}
function writePlan(outputPath: string, plan: PluginNpmSecurityArtifactDownloadPlan): void {
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(plan)}\n`, {
encoding: "utf8",
mode: 0o600,
});
}
function main(argv = process.argv.slice(2)): number {
const args = parseArgs(argv);
const artifactPages = JSON.parse(
readBoundedRegularFile(args.artifactMetadataPath, {
label: "Plugin security artifact API metadata",
maxBytes: MAX_ARTIFACT_METADATA_JSON_BYTES,
}).toString("utf8"),
) as unknown;
const plan = planPluginNpmSecurityArtifactDownloads({
artifactPages,
candidateSha: args.candidateSha,
expectedPackages: args.expectedPackages,
});
writePlan(args.outputPath, plan);
console.log(
`Plugin security artifact download plan: ${plan.artifacts.length}/${plan.expectedArtifactCount} artifacts, ${plan.totalBytes} bytes.`,
);
return 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
process.exitCode = main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
console.error("[plugin-npm-security-artifact-plan] FAILED (exit 1)");
process.exitCode = 1;
}
}
+4 -3
View File
@@ -48,7 +48,7 @@ function parseArgs(argv: string[]): ParsedArgs {
const name = argv[index];
const value = argv[index + 1];
if (!name?.startsWith("--") || value === undefined || values.has(name)) {
throw new Error(`Invalid plugin npm security prepare argument near ${String(name)}.`);
throw new Error(`Invalid plugin npm security prepare argument near ${name}.`);
}
values.set(name, value);
}
@@ -166,8 +166,9 @@ async function preparePackage(args: ParsedArgs): Promise<void> {
mkdirSync(args.outputDir, { recursive: true });
}
// Supplemental qualification keeps candidate code inert. Publication builds
// and scans its final artifact separately before any registry mutation.
// This is supplemental inert checked-in npm input, never a final or
// publication artifact. Exact-byte scanning is a future publisher redesign,
// not a capability of this workflow.
const npm = resolveNpmRunner({
env: {
CI: "1",
@@ -355,7 +355,6 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
"package directory is not a real directory",
);
const packageDir = tempDirs.make("openclaw-plugin-npm-security-symlink-");
const outsideFile = join(outsideDir, "outside.ts");
writeFileSync(outsideFile, "export const value = 1;\n", "utf8");
@@ -7,6 +7,12 @@ import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import { findLaneByName } from "../../scripts/lib/docker-e2e-plan.mts";
import { BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS } from "../../scripts/lib/docker-e2e-scenarios.mts";
import {
MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES,
MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES,
MAX_PUBLISHABLE_PLUGIN_PACKAGES,
planPluginNpmSecurityArtifactDownloads,
} from "../../scripts/lib/plugin-npm-security-scan.mts";
import {
PLUGIN_PRERELEASE_REQUIRED_SURFACES,
assertPluginPrereleaseTestPlanComplete,
@@ -21,7 +27,10 @@ const CHECKOUT_V6 = "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10";
const UPLOAD_ARTIFACT_V7 = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a";
type WorkflowStep = {
"continue-on-error"?: boolean;
env?: Record<string, string>;
id?: string;
if?: string;
name?: string;
run?: string;
uses?: string;
@@ -282,6 +291,8 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
const pluginWorkflow = readPluginPrereleaseWorkflow();
const pluginSource = readFileSync(".github/workflows/plugin-prerelease.yml", "utf8");
const securityPrepareSource = readFileSync("scripts/plugin-npm-security-prepare.mts", "utf8");
const securityScannerSource = readFileSync("scripts/lib/plugin-npm-security-scan.mts", "utf8");
const testingSkillSource = readFileSync(".agents/skills/openclaw-testing/SKILL.md", "utf8");
const resolver = pluginWorkflow.jobs["resolve-candidate"];
const securityPlan = pluginWorkflow.jobs["plugin-npm-security-plan"];
const securityPackage = pluginWorkflow.jobs["plugin-npm-security-package"];
@@ -302,6 +313,15 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
const runSecurityScan = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Scan supplemental inert plugin inputs",
);
const artifactDownloadPlan = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Bound supplemental inert plugin input downloads",
);
const downloadArtifacts = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Download bounded supplemental inert plugin inputs",
);
const normalizeSingleArtifact = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Normalize single supplemental inert plugin input",
);
const uploadReport = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Upload plugin npm security scan report",
);
@@ -350,6 +370,35 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
expect(runSecurityScan?.run).toContain("--artifact-root");
expect(runSecurityScan?.run).not.toContain("--candidate-root");
expect(runSecurityScan?.run).toContain('--candidate-sha "$CANDIDATE_SHA"');
expect(artifactDownloadPlan).toMatchObject({
"continue-on-error": true,
id: "artifact-download-plan",
});
expect(artifactDownloadPlan?.run).toContain("gh api --paginate --slurp");
expect(artifactDownloadPlan?.run).toContain("scripts/plugin-npm-security-artifact-plan.mts");
expect(artifactDownloadPlan?.run).toContain('echo "artifact_ids=$artifact_ids"');
expect(downloadArtifacts).toMatchObject({
"continue-on-error": true,
id: "download-bounded-artifacts",
if: "steps.artifact-download-plan.outcome == 'success' && steps.artifact-download-plan.outputs.artifact_ids != ''",
uses: "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c",
with: {
"artifact-ids": "${{ steps.artifact-download-plan.outputs.artifact_ids }}",
"digest-mismatch": "error",
"github-token": "${{ github.token }}",
path: "${{ runner.temp }}/plugin-npm-security-packages",
repository: "${{ github.repository }}",
"run-id": "${{ github.run_id }}",
},
});
expect(downloadArtifacts?.with).not.toHaveProperty("pattern");
expect(normalizeSingleArtifact?.if).toBe(
"steps.download-bounded-artifacts.outcome == 'success'",
);
expect(normalizeSingleArtifact?.run).toContain(
`[[ "$(jq '.artifacts | length' "$PLAN_PATH")" == "1" ]]`,
);
expect(normalizeSingleArtifact?.run).toContain('"$ARTIFACT_ROOT/$artifact_name/"');
expect(uploadReport).toMatchObject({
if: "always()",
uses: UPLOAD_ARTIFACT_V7,
@@ -386,6 +435,19 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
);
expect(securityPrepareSource).not.toContain("GITHUB_OUTPUT: process.env.GITHUB_OUTPUT");
expect(securityPrepareSource).toContain("resolveCandidatePluginPackageDir");
expect(securityPrepareSource).toContain("supplemental inert checked-in npm input");
expect(securityPrepareSource).toContain("future publisher redesign");
expect(securityPrepareSource).not.toContain("scans its final artifact separately");
expect(securityScannerSource).toContain("scanSupplementalInertPluginInput");
expect(securityScannerSource).toContain("supplemental inert package input identity mismatch");
expect(securityScannerSource).not.toContain("publication artifact identity mismatch");
expect(testingSkillSource).toContain(
"A future publisher redesign must scan the exact final bytes",
);
expect(testingSkillSource).toContain("the current\npublisher does not provide that guarantee");
expect(testingSkillSource).not.toContain(
"The publication workflow must independently scan its exact final artifact",
);
expect(pluginNpmReleaseSource).toContain("plugin-publication-artifact.mjs verify");
expect(nodeShard.needs).toEqual(["resolve-candidate", "preflight"]);
expect(runNodeShard?.run).toContain('spawnSync("pnpm", ["test", "--", ...configs]');
@@ -396,6 +458,72 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
expect(pluginDispatch?.run).not.toContain("node_test_exclude_patterns_json");
});
it("rejects oversized plugin security artifacts before the workflow download action", () => {
const candidateSha = "1".repeat(40);
const packageEntry = (index: number) => {
const id = `plugin-${String(index).padStart(3, "0")}`;
return {
extensionId: id,
packageDir: `extensions/${id}`,
packageName: `@openclaw/${id}`,
packageVersion: "1.0.0",
};
};
const artifactEntry = (
plugin: ReturnType<typeof packageEntry>,
index: number,
sizeInBytes: number,
) => ({
digest: `sha256:${String(index + 1).padStart(64, "0")}`,
expired: false,
id: index + 1,
name: `plugin-npm-security-package-${candidateSha}-${plugin.extensionId}`,
size_in_bytes: sizeInBytes,
});
const onePackage = [packageEntry(0)];
expect(() =>
planPluginNpmSecurityArtifactDownloads({
artifactPages: [
{
artifacts: [
artifactEntry(onePackage[0]!, 0, MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES + 1),
],
},
],
candidateSha,
expectedPackages: onePackage,
}),
).toThrow("pre-download byte limit");
const aggregatePackages = Array.from({ length: 5 }, (_, index) => packageEntry(index));
const aggregateArtifactBytes =
Math.floor(MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES / aggregatePackages.length) + 1;
expect(() =>
planPluginNpmSecurityArtifactDownloads({
artifactPages: [
{
artifacts: aggregatePackages.map((plugin, index) =>
artifactEntry(plugin, index, aggregateArtifactBytes),
),
},
],
candidateSha,
expectedPackages: aggregatePackages,
}),
).toThrow("aggregate pre-download byte limit");
expect(() =>
planPluginNpmSecurityArtifactDownloads({
artifactPages: [{ artifacts: [] }],
candidateSha,
expectedPackages: Array.from({ length: MAX_PUBLISHABLE_PLUGIN_PACKAGES + 1 }, (_, index) =>
packageEntry(index),
),
}),
).toThrow("Expected plugin package inventory is invalid");
});
it("keeps late candidate GITHUB_OUTPUT writes outside trusted candidate identity", () => {
const pluginWorkflow = readPluginPrereleaseWorkflow();
const preflight = pluginWorkflow.jobs.preflight;
@@ -427,7 +555,10 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
readFileSync(githubOutput, "utf8")
.trim()
.split("\n")
.map((line) => line.split("=", 2)),
.map((line): [string, string] => {
const [key = "", value = ""] = line.split("=", 2);
return [key, value];
}),
);
expect(lateCandidateOutput.get("checkout_revision")).toBe("f".repeat(40));