openclaw/scripts/ci-sdk-declarations.mts
Peter Steinberger 4167c4acb1
ci: share prepared SDK declarations across PR checks (#163094)
* ci: share prepared SDK declarations across PR checks

* ci: preserve author-independent SDK producer routing

* ci: avoid anchors in SDK composite action

* ci: export additional checks to downstream harnesses
2026-10-01 17:39:55 -07:00

257 lines
9.1 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { gzipSync, gunzipSync } from "node:zlib";
import { ensureKyselyTypes } from "./generate-kysely-types.mts";
import { readArtifactRecord } from "./lib/build-artifact-cache.mts";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts";
import {
BOUNDARY_CACHE_ROOT,
LOCAL_SDK_ROOT,
BoundaryInputSnapshot,
boundaryPreparationArgs,
boundaryRuntimeVersion,
sdkBoundaryUnit,
} from "./lib/extension-boundary-inputs.mts";
import { createDeclarationInputBoundary } from "./lib/local-check-runtime.mts";
import { nativeTypeScriptToolchainFiles } from "./lib/native-typescript-toolchain.mts";
const recordName = `${BOUNDARY_CACHE_ROOT}/plugin-sdk.json`;
const archiveName = ".artifacts/ci-sdk-declarations/sdk.json.gz";
const maxArchiveBytes = 64 * 1024 * 1024;
const maxExpandedBytes = 512 * 1024 * 1024;
const digest = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
function declarationPath(file: string) {
return (
!path.isAbsolute(file) &&
!file.includes("\\") &&
!Array.from(file).some((character) => character.charCodeAt(0) < 32) &&
!file.split("/").some((part) => !part || part === "." || part === "..") &&
(file === recordName ||
file === `${LOCAL_SDK_ROOT}/.inputs.json` ||
(file.startsWith(`${LOCAL_SDK_ROOT}/`) && /\.d\.[cm]?ts$/u.test(file)))
);
}
function assertRegularPath(root: string, file: string) {
const boundary = createDeclarationInputBoundary(root);
const target = boundary.assert(file);
for (let current = target; current !== boundary.root; current = path.dirname(current)) {
if (fs.lstatSync(current, { throwIfNoEntry: false })?.isSymbolicLink()) {
throw new Error("SDK transport does not accept symbolic links");
}
}
return target;
}
function currentSdk(root: string) {
const unit = sdkBoundaryUnit();
const inputReceipt = `${unit.outDir}/.inputs.json`;
const record = readArtifactRecord(path.join(root, recordName));
if (!record || !fs.existsSync(path.join(root, inputReceipt))) {
return undefined;
}
const snapshot = new BoundaryInputSnapshot(root);
return snapshot.matchesReceipt(
record,
unit.config,
boundaryPreparationArgs(root, unit),
[...unit.required, inputReceipt],
inputReceipt,
path.join(root, unit.outDir),
)
? record
: undefined;
}
/** Conservative source key locates candidates; the native receipt remains authoritative. */
function cacheIdentity(root: string) {
const boundary = createDeclarationInputBoundary(root);
const require = createRequire(path.join(root, "package.json"));
const nativePackage = boundary.assert(require.resolve("typescript/package.json"));
const tools = nativeTypeScriptToolchainFiles(nativePackage, (file) => boundary.assert(file));
const toolchain = digest(
JSON.stringify([
boundaryRuntimeVersion(),
process.platform,
process.arch,
...tools.map((file) => digest(fs.readFileSync(file))),
]),
);
const files = execFileSync("git", ["ls-files", "-z"], {
cwd: root,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
})
.split("\0")
.filter(
(file) =>
/^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc|tsconfig[^/]*\.json)$/u.test(
file,
) ||
/^(?:src|packages|extensions|scripts|test|config)\/.*\.(?:[cm]?[jt]sx?|json|sql|ya?ml)$/u.test(
file,
) ||
file.startsWith("patches/"),
)
.toSorted();
const sources = createHash("sha256");
for (const file of files) {
const absolute = boundary.assert(file);
const stat = fs.lstatSync(absolute);
sources.update(file).update("\0");
if (stat.isSymbolicLink()) {
sources.update(fs.readlinkSync(absolute)).update("\0");
}
sources.update(digest(fs.readFileSync(absolute))).update("\0");
}
const prefix = `native-sdk-v1-${process.platform}-${process.arch}-${toolchain}-`;
return { key: `${prefix}${sources.digest("hex")}`, prefix };
}
function pack(root: string) {
const record = currentSdk(root);
if (!record) {
throw new Error("Only a current full SDK receipt can be published");
}
const names = [...Object.keys(record.outputs), recordName].toSorted();
const files = names.map((file) => {
if (!declarationPath(file)) {
throw new Error("SDK receipt contains a non-declaration transport path");
}
const target = assertRegularPath(root, file);
if (!fs.statSync(target).isFile()) {
throw new Error("SDK transport requires regular declaration files");
}
return [file, fs.readFileSync(target, "utf8")];
});
const bytes = Buffer.from(JSON.stringify({ version: 1, key: cacheIdentity(root).key, files }));
if (bytes.length > maxExpandedBytes) {
throw new Error("Prepared SDK exceeds the transport size limit");
}
const packed = gzipSync(bytes);
if (packed.length > maxArchiveBytes) {
throw new Error("Prepared SDK archive exceeds the transport size limit");
}
const archive = assertRegularPath(root, archiveName);
fs.mkdirSync(path.dirname(archive), { recursive: true });
fs.writeFileSync(archive, packed);
console.log(`Prepared SDK archive: ${files.length} files, ${packed.length} bytes`);
}
function restore(root: string, required: boolean) {
const archive = assertRegularPath(root, archiveName);
const bytes = fs.readFileSync(archive);
if (bytes.length > maxArchiveBytes) {
throw new Error("Prepared SDK archive exceeds the transport size limit");
}
const payload: unknown = JSON.parse(
gunzipSync(bytes, { maxOutputLength: maxExpandedBytes }).toString("utf8"),
);
if (
!payload ||
typeof payload !== "object" ||
!("version" in payload) ||
payload.version !== 1 ||
!("key" in payload) ||
typeof payload.key !== "string" ||
!("files" in payload) ||
!Array.isArray(payload.files)
) {
throw new Error("Invalid prepared SDK archive");
}
if (required && payload.key !== cacheIdentity(root).key) {
throw new Error("Prepared SDK run artifact belongs to different source or toolchain inputs");
}
const seen = new Set<string>();
const files = payload.files.map((entry: unknown) => {
if (
!Array.isArray(entry) ||
entry.length !== 2 ||
typeof entry[0] !== "string" ||
typeof entry[1] !== "string" ||
!declarationPath(entry[0]) ||
seen.has(entry[0])
) {
throw new Error("Invalid or repeated prepared SDK file");
}
seen.add(entry[0]);
return { path: assertRegularPath(root, entry[0]), content: entry[1] };
});
if (!seen.has(recordName) || !seen.has(`${LOCAL_SDK_ROOT}/.inputs.json`)) {
throw new Error("Prepared SDK archive is missing its native receipt");
}
const sdk = assertRegularPath(root, LOCAL_SDK_ROOT);
const record = assertRegularPath(root, recordName);
fs.rmSync(record, { force: true });
fs.rmSync(sdk, { recursive: true, force: true });
try {
for (const file of files) {
fs.mkdirSync(path.dirname(file.path), { recursive: true });
fs.writeFileSync(file.path, file.content);
}
if (!currentSdk(root)) {
throw new Error("Prepared SDK receipt does not match current inputs and toolchain");
}
console.log(`Prepared SDK restored and validated: ${files.length} files`);
} catch (error) {
fs.rmSync(record, { force: true });
fs.rmSync(sdk, { recursive: true, force: true });
throw error;
}
}
async function main() {
const root = fs.realpathSync.native(process.cwd());
const [operation, ...args] = process.argv.slice(2);
await withDistArtifactOwnership(root, async () => {
await ensureKyselyTypes(root);
if (operation === "key") {
const identity = cacheIdentity(root);
const values = {
"cache-key": identity.key,
"cache-prefix": identity.prefix,
archive: archiveName,
fresh: String(Boolean(currentSdk(root))),
};
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries(values)
.map(([key, value]) => `${key}=${value}\n`)
.join(""),
);
}
console.log(JSON.stringify(values));
} else if (operation === "pack") {
pack(root);
} else if (operation === "restore") {
const required = args.includes("--required");
try {
restore(root, required);
} catch (error) {
if (required) {
throw error;
}
console.log(
`Prepared SDK cache unavailable: ${error instanceof Error ? error.message : "invalid archive"}; native preparation will run`,
);
}
} else if (operation === "validate") {
if (!currentSdk(root)) {
throw new Error("Prepared SDK receipt does not match current inputs and toolchain");
}
console.log("Prepared SDK receipt is current");
} else {
throw new Error(`Unknown SDK declaration operation: ${operation}`);
}
});
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
await main();
}