Files
openclaw/scripts/lib/actions-artifact-archive.mjs
Vincent Koc 54cc90b1b1 fix(release): publish ClawHub bootstrap artifacts immutably (#103809)
* fix(release): harden ClawHub bootstrap

* fix(release): centralize publication artifact verification

* fix(release): harden publication artifact verification

* fix(release): lock ClawHub bootstrap toolchain

* test(release): assert locked ClawHub binary

* fix(release): pack with trusted ClawHub harness

* fix(release): bound beta verifier artifact reads

* fix(release): bind target and tooling identities

* fix(release): stabilize signed package ordering

* fix(release): bind ClawHub pretag proof

* fix(release): bind ClawHub OIDC readback

* fix(release): bind ClawHub OIDC readback
2026-07-10 13:33:20 -07:00

995 lines
35 KiB
JavaScript

import { createHash } from "node:crypto";
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
import { basename } from "node:path";
import { inflateRawSync } from "node:zlib";
export const ACTIONS_ARTIFACT_API_VERSION = "2026-03-10";
export const DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES = 256 * 1024 * 1024;
export const DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES = 512 * 1024 * 1024;
const DEFAULT_MAX_JSON_BYTES = 2 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 60_000;
const ARTIFACT_DIGEST_RE = /^sha256:[0-9a-f]{64}$/u;
const ARTIFACT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
const COMMIT_SHA_RE = /^[0-9a-f]{40}$/u;
const REPOSITORY_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u;
const SUPPORTED_ZIP_FLAGS = 0x0808;
const ZIP_DATA_DESCRIPTOR_FLAG = 0x0008;
const ZIP_UTF8_FLAG = 0x0800;
const ZIP_LOCAL_HEADER_SIGNATURE = 0x04034b50;
const ZIP_CENTRAL_HEADER_SIGNATURE = 0x02014b50;
const ZIP_DATA_DESCRIPTOR_SIGNATURE = 0x08074b50;
const ZIP_EOCD_SIGNATURE = 0x06054b50;
const ZIP_EOCD_BYTES = 22;
const ZIP_DATA_DESCRIPTOR_BYTES = 16;
const ZIP_MAX_COMMENT_BYTES = 65_535;
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
function assertPositiveInteger(value, label) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive safe integer.`);
}
return value;
}
function assertTrimmedString(value, label) {
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
throw new Error(`${label} must be a non-empty trimmed string.`);
}
return value;
}
function hasControlCharacters(value) {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint <= 0x1f || codePoint === 0x7f) {
return true;
}
}
return false;
}
function boundedLimit(value, fallback, label) {
if (value === undefined) {
return fallback;
}
if (!Number.isSafeInteger(value) || value <= 0 || value > fallback) {
throw new Error(`${label} must be a positive safe integer no larger than ${fallback}.`);
}
return value;
}
function asBuffer(bytes, label) {
if (!(bytes instanceof Uint8Array)) {
throw new Error(`${label} must be a Uint8Array.`);
}
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
function decodeUtf8Exact(bytes, label) {
let value;
try {
value = UTF8_DECODER.decode(bytes);
} catch {
throw new Error(`${label} is not valid UTF-8.`);
}
if (!Buffer.from(value, "utf8").equals(bytes)) {
throw new Error(`${label} is not canonically encoded UTF-8.`);
}
return value;
}
function assertSafeArchivePath(value, label) {
if (
typeof value !== "string" ||
value.length === 0 ||
value.startsWith("/") ||
value.endsWith("/") ||
value.includes("\\") ||
value.includes("\0") ||
value.normalize("NFC") !== value ||
hasControlCharacters(value)
) {
throw new Error(`Unsafe ${label}: ${JSON.stringify(value)}`);
}
const parts = value.split("/");
if (parts.some((part) => part.length === 0 || part === "." || part === "..")) {
throw new Error(`Unsafe ${label}: ${JSON.stringify(value)}`);
}
return value;
}
function assertArtifactName(value) {
const name = assertTrimmedString(value, "Actions artifact name");
if (!ARTIFACT_NAME_RE.test(name)) {
throw new Error(`Invalid Actions artifact name: ${name}`);
}
return name;
}
function assertArtifactDigest(value) {
const digest = assertTrimmedString(value, "Actions artifact digest");
if (!ARTIFACT_DIGEST_RE.test(digest)) {
throw new Error("Actions artifact digest must be sha256:<64 lowercase hex>.");
}
return digest;
}
function assertCommitSha(value, label) {
const sha = assertTrimmedString(value, label);
if (!COMMIT_SHA_RE.test(sha)) {
throw new Error(`${label} must be a full lowercase commit SHA.`);
}
return sha;
}
function assertWorkflowPath(value) {
const workflowPath = assertTrimmedString(value, "workflow path");
if (
!/^\.github\/workflows\/[A-Za-z0-9][A-Za-z0-9_.-]*\.ya?ml$/u.test(workflowPath) ||
hasControlCharacters(workflowPath)
) {
throw new Error(`Invalid workflow path: ${workflowPath}`);
}
return workflowPath;
}
function assertRepository(value) {
const repository = assertTrimmedString(value, "GitHub repository");
if (!REPOSITORY_RE.test(repository)) {
throw new Error("GitHub repository must be owner/name.");
}
return repository;
}
export function sha256Digest(bytes) {
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
}
function compareCodeUnits(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
export function describeActionsArtifactFiles(files) {
if (!(files instanceof Map)) {
throw new Error("Actions artifact files must be a Map.");
}
return [...files.entries()]
.map(([path, bytes]) => {
const safePath = assertSafeArchivePath(path, "Actions artifact file path");
const content = asBuffer(bytes, `Actions artifact file ${safePath}`);
return {
path: safePath,
sha256: sha256Digest(content).slice("sha256:".length),
sizeBytes: content.byteLength,
};
})
.toSorted((left, right) => compareCodeUnits(left.path, right.path));
}
export function readBoundedRegularFile(path, params) {
if (!Number.isSafeInteger(params.maxBytes) || params.maxBytes <= 0) {
throw new Error(`${params.label} byte limit must be a positive safe integer.`);
}
let descriptor;
try {
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const before = fstatSync(descriptor);
if (!before.isFile()) {
throw new Error(`${params.label} must be a regular file.`);
}
if (before.size === 0 || before.size > params.maxBytes) {
throw new Error(`${params.label} size is outside the allowed range: ${before.size}.`);
}
const bytes = Buffer.allocUnsafe(before.size);
let offset = 0;
while (offset < bytes.length) {
const bytesRead = readSync(descriptor, bytes, offset, bytes.length - offset, offset);
if (bytesRead === 0) {
throw new Error(`${params.label} changed while it was being read.`);
}
offset += bytesRead;
}
const extra = Buffer.allocUnsafe(1);
const extraBytes = readSync(descriptor, extra, 0, 1, before.size);
const after = fstatSync(descriptor);
if (extraBytes !== 0 || after.size !== before.size) {
throw new Error(`${params.label} changed while it was being read.`);
}
return bytes;
} catch (error) {
if (error && typeof error === "object" && error.code === "ELOOP") {
throw new Error(`${params.label} must be a regular file.`, { cause: error });
}
throw error;
} finally {
if (descriptor !== undefined) {
closeSync(descriptor);
}
}
}
function findEndOfCentralDirectory(bytes) {
const minOffset = Math.max(0, bytes.length - ZIP_EOCD_BYTES - ZIP_MAX_COMMENT_BYTES);
for (let offset = bytes.length - ZIP_EOCD_BYTES; offset >= minOffset; offset -= 1) {
if (bytes.readUInt32LE(offset) !== ZIP_EOCD_SIGNATURE) {
continue;
}
const commentLength = bytes.readUInt16LE(offset + 20);
if (offset + ZIP_EOCD_BYTES + commentLength === bytes.length) {
if (commentLength !== 0) {
throw new Error("Actions artifact ZIP comments are not supported.");
}
return offset;
}
}
throw new Error(
"Actions artifact ZIP is missing an exact terminal end-of-central-directory record.",
);
}
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function normalizeArchivePolicy(policy) {
if (!policy || typeof policy !== "object") {
throw new Error("Actions artifact ZIP policy is required.");
}
const maxArchiveBytes = boundedLimit(
policy.maxArchiveBytes,
DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES,
"Actions artifact ZIP byte limit",
);
const maxExpandedBytes = boundedLimit(
policy.maxExpandedBytes,
DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES,
"Actions artifact ZIP expanded-byte limit",
);
let expectedEntries;
if (policy.expectedEntries !== undefined) {
if (
!Array.isArray(policy.expectedEntries) ||
policy.expectedEntries.length === 0 ||
policy.expectedEntries.length > 1024
) {
throw new Error("Actions artifact ZIP expected inventory is invalid.");
}
expectedEntries = policy.expectedEntries.map((name) =>
assertSafeArchivePath(name, "expected ZIP entry path"),
);
if (new Set(expectedEntries).size !== expectedEntries.length) {
throw new Error("Actions artifact ZIP expected inventory contains duplicates.");
}
}
const minEntries = expectedEntries?.length ?? policy.minEntries;
const maxEntries = expectedEntries?.length ?? policy.maxEntries;
if (
!Number.isSafeInteger(minEntries) ||
!Number.isSafeInteger(maxEntries) ||
minEntries <= 0 ||
maxEntries < minEntries ||
maxEntries > 1024
) {
throw new Error("Actions artifact ZIP entry-count policy is invalid.");
}
if (!expectedEntries && typeof policy.allowPath !== "function") {
throw new Error("Actions artifact ZIP path policy is required.");
}
if (typeof policy.maxEntryBytes !== "function") {
throw new Error("Actions artifact ZIP per-entry expanded-byte policy is required.");
}
if (
policy.maxCompressedEntryBytes !== undefined &&
typeof policy.maxCompressedEntryBytes !== "function"
) {
throw new Error("Actions artifact ZIP per-entry compressed-byte policy is invalid.");
}
return {
expectedEntries,
expectedEntrySet: expectedEntries ? new Set(expectedEntries) : undefined,
maxArchiveBytes,
maxEntries,
maxExpandedBytes,
minEntries,
rejectCaseFoldAliases: policy.rejectCaseFoldAliases !== false,
allowPath: expectedEntries
? (name) => new Set(expectedEntries).has(name)
: (name) => policy.allowPath(name),
maxCompressedEntryBytes: (name) =>
boundedLimit(
policy.maxCompressedEntryBytes?.(name),
maxArchiveBytes,
`Actions artifact ZIP compressed entry limit for ${name}`,
),
maxEntryBytes: (name) =>
boundedLimit(
policy.maxEntryBytes(name),
maxExpandedBytes,
`Actions artifact ZIP expanded entry limit for ${name}`,
),
};
}
function inspectCentralDirectory(bytes, eocd, policy) {
const disk = bytes.readUInt16LE(eocd + 4);
const centralDisk = bytes.readUInt16LE(eocd + 6);
const diskEntries = bytes.readUInt16LE(eocd + 8);
const totalEntries = bytes.readUInt16LE(eocd + 10);
const centralSize = bytes.readUInt32LE(eocd + 12);
const centralOffset = bytes.readUInt32LE(eocd + 16);
if (
disk !== 0 ||
centralDisk !== 0 ||
diskEntries !== totalEntries ||
totalEntries === 0xffff ||
centralSize === 0xffffffff ||
centralOffset === 0xffffffff
) {
throw new Error("Multi-disk and ZIP64 Actions artifacts are not supported.");
}
if (
totalEntries < policy.minEntries ||
totalEntries > policy.maxEntries ||
centralOffset + centralSize !== eocd
) {
throw new Error(
`Actions artifact ZIP must contain between ${policy.minEntries} and ${policy.maxEntries} exact files with a contiguous central directory.`,
);
}
const records = [];
const names = new Set();
const aliases = new Set();
let offset = centralOffset;
let declaredExpandedBytes = 0;
for (let index = 0; index < totalEntries; index += 1) {
if (offset + 46 > eocd || bytes.readUInt32LE(offset) !== ZIP_CENTRAL_HEADER_SIGNATURE) {
throw new Error("Invalid Actions artifact ZIP central-directory entry.");
}
const versionNeeded = bytes.readUInt16LE(offset + 6);
const flags = bytes.readUInt16LE(offset + 8);
const compression = bytes.readUInt16LE(offset + 10);
const expectedCrc = bytes.readUInt32LE(offset + 16);
const compressedSize = bytes.readUInt32LE(offset + 20);
const expandedSize = bytes.readUInt32LE(offset + 24);
const nameLength = bytes.readUInt16LE(offset + 28);
const extraLength = bytes.readUInt16LE(offset + 30);
const commentLength = bytes.readUInt16LE(offset + 32);
const startDisk = bytes.readUInt16LE(offset + 34);
const externalAttributes = bytes.readUInt32LE(offset + 38);
const localOffset = bytes.readUInt32LE(offset + 42);
const nameStart = offset + 46;
const nameEnd = nameStart + nameLength;
const nextOffset = nameEnd + extraLength + commentLength;
if (nextOffset > eocd) {
throw new Error("Actions artifact ZIP central directory exceeds its declared boundary.");
}
if (
versionNeeded > 20 ||
startDisk !== 0 ||
nameLength === 0 ||
extraLength !== 0 ||
commentLength !== 0
) {
throw new Error("Actions artifact ZIP central-directory encoding is not canonical.");
}
if ((flags & ~SUPPORTED_ZIP_FLAGS) !== 0 || (flags & 0x0001) !== 0) {
throw new Error(`Unsupported Actions artifact ZIP flags 0x${flags.toString(16)}.`);
}
if (compression !== 0 && compression !== 8) {
throw new Error(`Unsupported Actions artifact ZIP compression method ${compression}.`);
}
if (
compressedSize === 0xffffffff ||
expandedSize === 0xffffffff ||
localOffset === 0xffffffff
) {
throw new Error("ZIP64 Actions artifact entries are not supported.");
}
const rawName = Buffer.from(bytes.subarray(nameStart, nameEnd));
if ((flags & ZIP_UTF8_FLAG) === 0 && rawName.some((byte) => byte >= 0x80)) {
throw new Error(
"Actions artifact ZIP non-ASCII entry names must set the UTF-8 language flag.",
);
}
const name = assertSafeArchivePath(
decodeUtf8Exact(rawName, "Actions artifact ZIP entry path"),
"ZIP entry path",
);
const alias = name.toLocaleLowerCase("en-US");
if (
names.has(name) ||
(policy.rejectCaseFoldAliases && aliases.has(alias)) ||
!policy.allowPath(name)
) {
throw new Error(`Unexpected, duplicate, or aliased Actions artifact ZIP entry: ${name}`);
}
names.add(name);
aliases.add(alias);
const unixMode = (externalAttributes >>> 16) & 0xffff;
const unixType = unixMode & 0o170000;
const dosAttributes = externalAttributes & 0xffff;
if ((unixType !== 0 && unixType !== 0o100000) || (dosAttributes & 0x10) !== 0) {
throw new Error(`Actions artifact ZIP entry is not a regular file: ${name}`);
}
const maxCompressedBytes = policy.maxCompressedEntryBytes(name);
const maxEntryBytes = policy.maxEntryBytes(name);
if (
compressedSize > maxCompressedBytes ||
expandedSize > maxEntryBytes ||
declaredExpandedBytes + expandedSize > policy.maxExpandedBytes
) {
throw new Error(`Actions artifact ZIP entry is too large: ${name}`);
}
if (compression === 0 && compressedSize !== expandedSize) {
throw new Error(`Stored Actions artifact ZIP entry size mismatch for ${name}.`);
}
declaredExpandedBytes += expandedSize;
records.push({
compressedSize,
compression,
expandedSize,
expectedCrc,
flags,
localOffset,
maxEntryBytes,
name,
rawName,
versionNeeded,
});
offset = nextOffset;
}
if (offset !== eocd) {
throw new Error("Actions artifact ZIP central-directory size mismatch.");
}
return { centralOffset, records };
}
function expandZipEntry(compressed, record, remainingExpandedBytes) {
const outputLimit = Math.min(record.maxEntryBytes, remainingExpandedBytes);
try {
if (record.compression === 0) {
return compressed;
}
const result = inflateRawSync(compressed, {
info: true,
maxOutputLength: Math.max(1, outputLimit),
});
if (result.engine.bytesWritten !== compressed.length) {
throw new Error("deflate stream does not consume its full compressed member");
}
return result.buffer;
} catch (error) {
throw new Error(
`Actions artifact ZIP entry expansion exceeds its allowed range: ${record.name}`,
{ cause: error },
);
}
}
function inspectLocalRecords(bytes, centralOffset, records, policy) {
const files = new Map();
const ordered = records.toSorted((left, right) => left.localOffset - right.localOffset);
let expectedOffset = 0;
let totalExpandedBytes = 0;
for (const [index, record] of ordered.entries()) {
if (
record.localOffset !== expectedOffset ||
record.localOffset + 30 > centralOffset ||
bytes.readUInt32LE(record.localOffset) !== ZIP_LOCAL_HEADER_SIGNATURE
) {
throw new Error(
`Non-contiguous or invalid Actions artifact ZIP local record: ${record.name}`,
);
}
const localVersionNeeded = bytes.readUInt16LE(record.localOffset + 4);
const localFlags = bytes.readUInt16LE(record.localOffset + 6);
const localCompression = bytes.readUInt16LE(record.localOffset + 8);
const localCrc = bytes.readUInt32LE(record.localOffset + 14);
const localCompressedSize = bytes.readUInt32LE(record.localOffset + 18);
const localExpandedSize = bytes.readUInt32LE(record.localOffset + 22);
const localNameLength = bytes.readUInt16LE(record.localOffset + 26);
const localExtraLength = bytes.readUInt16LE(record.localOffset + 28);
const localNameStart = record.localOffset + 30;
const localNameEnd = localNameStart + localNameLength;
if (
localNameEnd > centralOffset ||
localVersionNeeded !== record.versionNeeded ||
localFlags !== record.flags ||
localCompression !== record.compression ||
localExtraLength !== 0
) {
throw new Error(`Actions artifact ZIP local header mismatch for ${record.name}.`);
}
const rawLocalName = bytes.subarray(localNameStart, localNameEnd);
if (!record.rawName.equals(rawLocalName)) {
throw new Error(`Actions artifact ZIP local and central names differ for ${record.name}.`);
}
decodeUtf8Exact(rawLocalName, `Actions artifact ZIP local entry path for ${record.name}`);
const dataStart = localNameEnd;
const dataEnd = dataStart + record.compressedSize;
if (dataEnd > centralOffset) {
throw new Error(`Actions artifact ZIP data exceeds its boundary: ${record.name}`);
}
let recordEnd = dataEnd;
if ((record.flags & ZIP_DATA_DESCRIPTOR_FLAG) !== 0) {
if (localCrc !== 0 || localCompressedSize !== 0 || localExpandedSize !== 0) {
throw new Error(
`Actions artifact ZIP descriptor-backed local sizes must be zero: ${record.name}`,
);
}
if (
dataEnd + ZIP_DATA_DESCRIPTOR_BYTES > centralOffset ||
bytes.readUInt32LE(dataEnd) !== ZIP_DATA_DESCRIPTOR_SIGNATURE ||
bytes.readUInt32LE(dataEnd + 4) !== record.expectedCrc ||
bytes.readUInt32LE(dataEnd + 8) !== record.compressedSize ||
bytes.readUInt32LE(dataEnd + 12) !== record.expandedSize
) {
throw new Error(`Invalid Actions artifact ZIP data descriptor for ${record.name}.`);
}
recordEnd += ZIP_DATA_DESCRIPTOR_BYTES;
} else if (
localCrc !== record.expectedCrc ||
localCompressedSize !== record.compressedSize ||
localExpandedSize !== record.expandedSize
) {
throw new Error(`Actions artifact ZIP local sizes or CRC differ for ${record.name}.`);
}
const nextOffset = ordered[index + 1]?.localOffset ?? centralOffset;
if (recordEnd !== nextOffset) {
throw new Error(`Actions artifact ZIP contains a gap or overlap after ${record.name}.`);
}
const compressed = bytes.subarray(dataStart, dataEnd);
const expanded = expandZipEntry(
compressed,
record,
policy.maxExpandedBytes - totalExpandedBytes,
);
if (
expanded.length !== record.expandedSize ||
totalExpandedBytes + expanded.length > policy.maxExpandedBytes ||
crc32(expanded) !== record.expectedCrc
) {
throw new Error(`Actions artifact ZIP checksum mismatch for ${record.name}.`);
}
totalExpandedBytes += expanded.length;
files.set(record.name, Buffer.from(expanded));
expectedOffset = recordEnd;
}
if (expectedOffset !== centralOffset) {
throw new Error("Actions artifact ZIP local records do not end at the central directory.");
}
return files;
}
export function inspectActionsArtifactZipWithPolicy(inputBytes, inputPolicy) {
const bytes = asBuffer(inputBytes, "Actions artifact ZIP");
const policy = normalizeArchivePolicy(inputPolicy);
if (bytes.length === 0 || bytes.length > policy.maxArchiveBytes) {
throw new Error(`Actions artifact ZIP size is outside the allowed range: ${bytes.length}.`);
}
const eocd = findEndOfCentralDirectory(bytes);
const { centralOffset, records } = inspectCentralDirectory(bytes, eocd, policy);
const files = inspectLocalRecords(bytes, centralOffset, records, policy);
if (policy.expectedEntries) {
const actual = [...files.keys()].toSorted(compareCodeUnits);
const expected = [...policy.expectedEntries].toSorted(compareCodeUnits);
if (
actual.length !== expected.length ||
actual.some((name, index) => name !== expected[index])
) {
throw new Error(
`Actions artifact ZIP inventory mismatch: expected ${expected.join(", ")}, found ${actual.join(", ")}.`,
);
}
}
return files;
}
export function inspectActionsArtifactZip(bytes, expectedEntries = 2, limits = {}) {
let expectedInventory;
let expectedCount;
if (Array.isArray(expectedEntries)) {
expectedInventory = expectedEntries;
expectedCount = expectedEntries.length;
} else {
expectedCount = assertPositiveInteger(expectedEntries, "Expected Actions artifact entry count");
}
const maxArchiveBytes = boundedLimit(
limits.maxArchiveBytes,
DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES,
"Actions artifact ZIP byte limit",
);
const maxExpandedBytes = boundedLimit(
limits.maxExpandedBytes,
DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES,
"Actions artifact ZIP expanded-byte limit",
);
const maxEntryBytes = boundedLimit(
limits.maxEntryBytes,
maxExpandedBytes,
"Actions artifact ZIP expanded entry limit",
);
const maxCompressedEntryBytes = boundedLimit(
limits.maxCompressedEntryBytes,
maxArchiveBytes,
"Actions artifact ZIP compressed entry limit",
);
return inspectActionsArtifactZipWithPolicy(bytes, {
expectedEntries: expectedInventory,
minEntries: expectedCount,
maxEntries: expectedCount,
maxArchiveBytes,
maxExpandedBytes,
allowPath: expectedInventory ? undefined : (name) => basename(name) === name,
maxCompressedEntryBytes: () => maxCompressedEntryBytes,
maxEntryBytes: () => maxEntryBytes,
});
}
function requireExpectedBinding(params) {
const expected = params.expected;
if (!expected || typeof expected !== "object") {
throw new Error("Expected Actions artifact binding is required.");
}
const repository = assertRepository(expected.repository);
const artifactId = assertPositiveInteger(expected.artifactId, "Actions artifact ID");
const artifactName = assertArtifactName(expected.artifactName);
const artifactDigest = assertArtifactDigest(expected.artifactDigest);
const artifactSizeBytes = assertPositiveInteger(
expected.artifactSizeBytes,
"Actions artifact size",
);
const runId = assertPositiveInteger(expected.runId, "workflow run ID");
const runAttempt = assertPositiveInteger(expected.runAttempt, "workflow run attempt");
const workflowSha = assertCommitSha(expected.workflowSha, "workflow SHA");
const workflowPath = assertWorkflowPath(expected.workflowPath);
const workflowEvent = assertTrimmedString(expected.workflowEvent, "workflow event");
const workflowHeadBranch = assertTrimmedString(
expected.workflowHeadBranch,
"workflow head branch",
);
const runStatePolicy = assertTrimmedString(expected.runStatePolicy, "workflow run-state policy");
if (runStatePolicy !== "completed-success" && runStatePolicy !== "same-run-producer-success") {
throw new Error(`Unsupported workflow run-state policy: ${runStatePolicy}`);
}
const consumerRunAttempt =
runStatePolicy === "same-run-producer-success"
? assertPositiveInteger(expected.consumerRunAttempt, "consumer workflow run attempt")
: undefined;
const producerJobName =
runStatePolicy === "same-run-producer-success"
? assertTrimmedString(expected.producerJobName, "producer job name")
: undefined;
if (consumerRunAttempt !== undefined && runAttempt > consumerRunAttempt) {
throw new Error("Producer workflow run attempt must not be newer than the consumer attempt.");
}
return {
artifactDigest,
artifactId,
artifactName,
artifactSizeBytes,
consumerRunAttempt,
producerJobName,
repository,
runStatePolicy,
runAttempt,
runId,
workflowEvent,
workflowHeadBranch,
workflowPath,
workflowSha,
};
}
export function validateActionsArtifactBinding(params) {
const expected = requireExpectedBinding(params);
const artifact = params.artifactMetadata;
const run = params.workflowRun;
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
throw new Error("Actions artifact metadata must be an object.");
}
if (!run || typeof run !== "object" || Array.isArray(run)) {
throw new Error("Actions workflow run metadata must be an object.");
}
if (
artifact.id !== expected.artifactId ||
artifact.name !== expected.artifactName ||
artifact.size_in_bytes !== expected.artifactSizeBytes ||
artifact.expired !== false ||
artifact.digest !== expected.artifactDigest ||
artifact.workflow_run?.id !== expected.runId ||
artifact.workflow_run?.head_sha !== expected.workflowSha
) {
throw new Error("Actions artifact metadata does not match the immutable publication tuple.");
}
if (
run.id !== expected.runId ||
run.run_attempt !== expected.runAttempt ||
run.head_sha !== expected.workflowSha ||
run.head_branch !== expected.workflowHeadBranch ||
run.event !== expected.workflowEvent ||
run.path !== expected.workflowPath ||
run.repository?.full_name !== expected.repository ||
run.head_repository?.full_name !== expected.repository
) {
throw new Error("Actions workflow run does not match the immutable publication tuple.");
}
if (expected.runStatePolicy === "completed-success") {
if (run.status !== "completed" || run.conclusion !== "success") {
throw new Error("Actions workflow run does not match the immutable publication tuple.");
}
} else if (expected.runAttempt === expected.consumerRunAttempt) {
if (run.status !== "in_progress" || run.conclusion !== null) {
throw new Error("Current producer workflow attempt must still be in progress.");
}
} else if (
run.status !== "completed" ||
typeof run.conclusion !== "string" ||
run.conclusion.length === 0
) {
throw new Error("Prior producer workflow attempt must be completed.");
}
return expected;
}
export function validateActionsArtifactProducerJob(params) {
const expected = requireExpectedBinding(params);
if (expected.runStatePolicy !== "same-run-producer-success") {
return expected;
}
const response = params.workflowJobs;
if (!response || typeof response !== "object" || Array.isArray(response)) {
throw new Error("Actions workflow jobs response must be an object.");
}
if (
!Number.isSafeInteger(response.total_count) ||
response.total_count < 0 ||
!Array.isArray(response.jobs) ||
response.total_count !== response.jobs.length
) {
throw new Error("Actions workflow jobs inventory is incomplete.");
}
const matches = response.jobs.filter((job) => job?.name === expected.producerJobName);
if (matches.length !== 1) {
throw new Error("Actions artifact producer job must be unique.");
}
const [producerJob] = matches;
if (
producerJob.run_id !== expected.runId ||
producerJob.run_attempt !== expected.runAttempt ||
producerJob.head_sha !== expected.workflowSha ||
producerJob.status !== "completed" ||
producerJob.conclusion !== "success"
) {
throw new Error("Actions artifact producer job did not complete successfully.");
}
return expected;
}
async function readBoundedResponseBody(response, params) {
if (!response.ok || !response.body) {
await response.body?.cancel();
throw new Error(`${params.label} returned HTTP ${response.status}.`);
}
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
if (!/^(?:0|[1-9][0-9]*)$/u.test(contentLength)) {
await response.body?.cancel();
throw new Error(`${params.label} returned an invalid Content-Length.`);
}
const declaredBytes = Number(contentLength);
if (
!Number.isSafeInteger(declaredBytes) ||
declaredBytes > params.maxBytes ||
(params.expectedBytes !== undefined && declaredBytes !== params.expectedBytes)
) {
await response.body?.cancel();
throw new Error(`${params.label} Content-Length is outside the approved range.`);
}
}
const chunks = [];
let totalBytes = 0;
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
totalBytes += value.byteLength;
if (
totalBytes > params.maxBytes ||
(params.expectedBytes !== undefined && totalBytes > params.expectedBytes)
) {
await reader.cancel();
throw new Error(`${params.label} exceeded its approved byte count.`);
}
chunks.push(Buffer.from(value));
}
} catch (error) {
await reader.cancel().catch(() => undefined);
throw error;
} finally {
reader.releaseLock();
}
if (totalBytes === 0) {
throw new Error(`${params.label} returned an empty body.`);
}
if (params.expectedBytes !== undefined && totalBytes !== params.expectedBytes) {
throw new Error(`${params.label} size does not match metadata.`);
}
return Buffer.concat(chunks, totalBytes);
}
async function runBoundedRetry(label, operation, params) {
let lastError;
for (let attempt = 1; attempt <= params.attempts; attempt += 1) {
try {
return await operation(attempt);
} catch (error) {
lastError = error;
if (attempt === params.attempts) {
break;
}
await new Promise((resolvePromise) => {
setTimeout(resolvePromise, params.delayMs);
});
}
}
throw new Error(
`${label} failed after ${params.attempts} attempts: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`,
{ cause: lastError },
);
}
async function fetchBoundedJson(url, request, params) {
const response = await request.fetchImpl(url, {
headers: request.headers,
redirect: "follow",
signal: AbortSignal.timeout(request.timeoutMs),
});
const bytes = await readBoundedResponseBody(response, {
label: params.label,
maxBytes: params.maxBytes,
});
let value;
try {
value = JSON.parse(decodeUtf8Exact(bytes, `${params.label} body`));
} catch (error) {
throw new Error(
`${params.label} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${params.label} JSON must be an object.`);
}
return value;
}
export async function downloadActionsArtifactArchive(params) {
const expected = requireExpectedBinding(params);
const token = assertTrimmedString(params.token, "GitHub token");
const timeoutMs = boundedLimit(params.timeoutMs, DEFAULT_TIMEOUT_MS, "GitHub request timeout");
const retryAttempts =
params.retryAttempts === undefined
? 3
: boundedLimit(params.retryAttempts, 5, "GitHub request retry count");
const retryDelayMs =
params.retryDelayMs === undefined
? 250
: boundedLimit(params.retryDelayMs, 5_000, "GitHub retry delay");
const maxArchiveBytes = boundedLimit(
params.maxArchiveBytes,
DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES,
"Actions artifact ZIP byte limit",
);
if (expected.artifactSizeBytes > maxArchiveBytes) {
throw new Error("Actions artifact size exceeds the configured archive limit.");
}
const fetchImpl = params.fetchImpl ?? fetch;
const headers = {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": "openclaw-publication-artifact",
"x-github-api-version": ACTIONS_ARTIFACT_API_VERSION,
};
const apiRoot = `https://api.github.com/repos/${expected.repository}`;
const request = { fetchImpl, headers, timeoutMs };
const retry = {
attempts: retryAttempts,
delayMs: retryDelayMs,
};
const artifactMetadata = await runBoundedRetry(
"GitHub Actions artifact metadata",
() =>
fetchBoundedJson(`${apiRoot}/actions/artifacts/${expected.artifactId}`, request, {
label: "GitHub Actions artifact metadata",
maxBytes: DEFAULT_MAX_JSON_BYTES,
}),
retry,
);
const workflowRun = await runBoundedRetry(
"GitHub Actions workflow attempt",
() =>
fetchBoundedJson(
`${apiRoot}/actions/runs/${expected.runId}/attempts/${expected.runAttempt}`,
request,
{
label: "GitHub Actions workflow attempt",
maxBytes: DEFAULT_MAX_JSON_BYTES,
},
),
retry,
);
validateActionsArtifactBinding({ artifactMetadata, expected, workflowRun });
let workflowJobs;
if (expected.runStatePolicy === "same-run-producer-success") {
workflowJobs = await runBoundedRetry(
"GitHub Actions producer jobs",
() =>
fetchBoundedJson(
`${apiRoot}/actions/runs/${expected.runId}/attempts/${expected.runAttempt}/jobs?per_page=100`,
request,
{
label: "GitHub Actions producer jobs",
maxBytes: DEFAULT_MAX_JSON_BYTES,
},
),
retry,
);
validateActionsArtifactProducerJob({ expected, workflowJobs });
}
const archiveBytes = await runBoundedRetry(
"GitHub Actions artifact download",
async () => {
const response = await fetchImpl(`${apiRoot}/actions/artifacts/${expected.artifactId}/zip`, {
headers,
redirect: "follow",
signal: AbortSignal.timeout(timeoutMs),
});
const bytes = await readBoundedResponseBody(response, {
expectedBytes: expected.artifactSizeBytes,
label: "GitHub Actions artifact download",
maxBytes: maxArchiveBytes,
});
const actualDigest = sha256Digest(bytes);
if (actualDigest !== expected.artifactDigest) {
throw new Error(
`GitHub Actions artifact digest ${actualDigest} does not match ${expected.artifactDigest}.`,
);
}
return bytes;
},
retry,
);
return { archiveBytes, artifactMetadata, binding: expected, workflowJobs, workflowRun };
}
export async function readPublicationArtifactArchive(params) {
const downloaded = await downloadActionsArtifactArchive(params);
const files = inspectActionsArtifactZipWithPolicy(downloaded.archiveBytes, params.archivePolicy);
return { ...downloaded, files };
}