mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
feat: componentize GStack 2 runtime and release integrity
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const [directory, repository = process.env.GITHUB_REPOSITORY, version = "2.0.0"] = process.argv.slice(2);
|
||||
if (!directory || !repository) {
|
||||
console.error("Usage: create-runtime-release-manifest.mjs <artifact-dir> <owner/repo> [version]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const targets = [
|
||||
"darwin-arm64",
|
||||
"darwin-x64",
|
||||
"linux-arm64",
|
||||
"linux-x64",
|
||||
"windows-arm64",
|
||||
"windows-x64",
|
||||
];
|
||||
const componentDependencies = {
|
||||
core: [],
|
||||
"browser-code": ["core"],
|
||||
"browser-headless": ["browser-code"],
|
||||
"browser-visible": ["browser-code"],
|
||||
design: ["core"],
|
||||
diagram: ["browser-headless"],
|
||||
pdf: ["diagram"],
|
||||
ios: ["core"],
|
||||
};
|
||||
const capabilityComponents = {
|
||||
browser: ["browser-code", "browser-headless"],
|
||||
"browser-visible": ["browser-code", "browser-visible"],
|
||||
design: ["design"],
|
||||
diagram: ["diagram"],
|
||||
pdf: ["pdf"],
|
||||
ios: ["ios"],
|
||||
};
|
||||
const commonComponents = ["core", "browser-code", "browser-headless", "browser-visible", "design", "diagram", "pdf"];
|
||||
const release = `https://github.com/${repository}/releases/download/v${version}`;
|
||||
const certificateIdentity = `https://github.com/${repository}/.github/workflows/release-artifacts.yml@refs/tags/v${version}`;
|
||||
const targetRecords = {};
|
||||
|
||||
for (const target of targets) {
|
||||
const ids = [...commonComponents, ...(target.startsWith("darwin-") ? ["ios"] : [])];
|
||||
const components = {};
|
||||
for (const component of ids) {
|
||||
const name = `gstack-runtime-${version}-${target}-${component}.tar.gz`;
|
||||
const archive = path.join(directory, name);
|
||||
const bundle = `${archive}.sigstore.json`;
|
||||
const digestFile = `${archive}.sha256`;
|
||||
const [stat, digest, bundleStat] = await Promise.all([
|
||||
fs.stat(archive),
|
||||
fs.readFile(digestFile, "utf8"),
|
||||
fs.stat(bundle),
|
||||
]);
|
||||
const sha256 = digest.trim().split(/\s+/)[0];
|
||||
if (!stat.isFile() || stat.size <= 0 || stat.size > 2 * 1024 * 1024 * 1024) {
|
||||
throw new Error(`Invalid artifact size for ${name}: ${stat.size}`);
|
||||
}
|
||||
if (!bundleStat.isFile() || bundleStat.size <= 0) throw new Error(`Missing Sigstore bundle for ${name}`);
|
||||
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error(`Invalid SHA-256 for ${name}`);
|
||||
components[component] = {
|
||||
url: `${release}/${name}`,
|
||||
sha256,
|
||||
bytes: stat.size,
|
||||
format: "tar.gz",
|
||||
root: "gstack",
|
||||
cosignBundleUrl: `${release}/${name}.sigstore.json`,
|
||||
certificateIdentity,
|
||||
certificateOidcIssuer: "https://token.actions.githubusercontent.com",
|
||||
};
|
||||
}
|
||||
targetRecords[target] = { components };
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 2,
|
||||
version,
|
||||
skillApi: "2.0",
|
||||
capabilityComponents,
|
||||
componentDependencies,
|
||||
targets: targetRecords,
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(directory, "gstack-runtime-manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
{ flag: "wx", mode: 0o644 },
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const child = spawn("bun", [
|
||||
"bin/gstack-redact",
|
||||
"--repo-visibility", "public",
|
||||
"--json",
|
||||
"--max-bytes", "16000000",
|
||||
], { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "inherit"] });
|
||||
let diff = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => { diff += chunk; });
|
||||
process.stdin.once("end", () => {
|
||||
const additions = diff
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
|
||||
.map((line) => line.slice(1))
|
||||
.join("\n");
|
||||
child.stdin.end(additions);
|
||||
});
|
||||
let stdout = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.once("error", (error) => { throw error; });
|
||||
child.once("close", (code) => {
|
||||
const report = JSON.parse(stdout);
|
||||
const high = Number(report.counts?.HIGH ?? 0);
|
||||
const medium = Number(report.counts?.MEDIUM ?? 0);
|
||||
console.log(`credential scan: ${high} high, ${medium} advisory`);
|
||||
process.exitCode = high > 0 || report.oversize || ![0, 2, 3].includes(code) ? 1 : 0;
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const root = path.resolve(process.argv[2] ?? process.cwd());
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-npm-smoke-"));
|
||||
try {
|
||||
const pack = await run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", temporary], root);
|
||||
const [metadata] = JSON.parse(pack.stdout);
|
||||
if (!metadata?.filename || metadata.size > 2_000_000 || metadata.entryCount > 80) {
|
||||
throw new Error(`Unexpected npm package shape: ${JSON.stringify(metadata)}`);
|
||||
}
|
||||
const archive = path.join(temporary, metadata.filename);
|
||||
const project = path.join(temporary, "consumer");
|
||||
await fs.mkdir(project);
|
||||
await fs.writeFile(path.join(project, "package.json"), '{"private":true}\n');
|
||||
await run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false", archive], project);
|
||||
|
||||
const installed = path.join(project, "node_modules", "gstack");
|
||||
const pkg = JSON.parse(await fs.readFile(path.join(installed, "package.json"), "utf8"));
|
||||
if (pkg.gstack?.packageRole !== "runtime-control") throw new Error("Packed role metadata is missing");
|
||||
for (const excluded of ["skills", "browse", "design", "make-pdf", "setup"]) {
|
||||
if (await exists(path.join(installed, excluded))) throw new Error(`Packed package unexpectedly contains ${excluded}`);
|
||||
}
|
||||
|
||||
const state = path.join(temporary, "state");
|
||||
const env = { ...process.env, GSTACK_HOME: state };
|
||||
const version = await run(process.execPath, [path.join(installed, "bin", "gstack"), "--version"], project, env);
|
||||
if (!version.stdout.includes(`gstack runtime ${pkg.gstack.runtimeVersion}`)) throw new Error("Packed gstack version mismatch");
|
||||
await run(process.execPath, [path.join(installed, "bin", "gstack"), "setup"], project, env);
|
||||
const doctor = await run(process.execPath, [path.join(installed, "bin", "gstack"), "doctor", "--json"], project, env, [0, 1]);
|
||||
const report = JSON.parse(doctor.stdout);
|
||||
if (!Array.isArray(report.checks) || !report.checks.some((check) => check.id === "config" && check.status === "pass")) {
|
||||
throw new Error("Packed setup/doctor did not initialize isolated runtime state");
|
||||
}
|
||||
const help = await run(process.execPath, [path.join(installed, "runtime", "runtime-bootstrap.mjs"), "--help"], project, env);
|
||||
if (!help.stdout.includes("Usage:")) {
|
||||
throw new Error(`Packed runtime bootstrap help is unavailable: ${JSON.stringify(help)}`);
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, size: metadata.size, entryCount: metadata.entryCount, version: pkg.version }));
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
return fs.access(target).then(() => true, () => false);
|
||||
}
|
||||
|
||||
function run(command, args, cwd, env = process.env, allowed = [0]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { cwd, env, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => allowed.includes(code) ? resolve({ stdout, stderr, code }) : reject(new Error(`${command} ${args.join(" ")} failed (${code})\n${stderr}`)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants as fsConstants, createReadStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
RUNTIME_COMPONENT_DEPENDENCIES,
|
||||
runtimeReleaseComponentForPath,
|
||||
} from "../../runtime/install.js";
|
||||
|
||||
const [activeInput, outputInput] = process.argv.slice(2);
|
||||
if (!activeInput || !outputInput) {
|
||||
console.error("Usage: stage-runtime-components.mjs <active-runtime> <output-dir>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const active = await fs.realpath(path.resolve(activeInput));
|
||||
const output = path.resolve(outputInput);
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(active, ".gstack-bundle.json"), "utf8"));
|
||||
if (manifest?.schemaVersion !== 2 || !Array.isArray(manifest.files)) {
|
||||
throw new Error("Active runtime has no supported bundle manifest");
|
||||
}
|
||||
|
||||
const componentIds = Object.keys(RUNTIME_COMPONENT_DEPENDENCIES).sort();
|
||||
const summary = Object.fromEntries(componentIds.map((id) => [id, { files: 0, bytes: 0 }]));
|
||||
for (const id of componentIds) await fs.mkdir(path.join(output, id, "gstack"), { recursive: true, mode: 0o700 });
|
||||
|
||||
for (const file of manifest.files) {
|
||||
if (!file || typeof file.path !== "string" || !/^[a-f0-9]{64}$/.test(file.sha256) ||
|
||||
!Number.isSafeInteger(file.size) || file.size < 0) {
|
||||
throw new Error("Bundle manifest contains an invalid file record");
|
||||
}
|
||||
const relative = normalizeRelative(file.path);
|
||||
const component = runtimeReleaseComponentForPath(relative);
|
||||
if (component == null) continue; // Playwright's local GC bookkeeping is not runtime input.
|
||||
if (!summary[component]) throw new Error(`No release component declared for ${relative}`);
|
||||
const source = safeJoin(active, relative);
|
||||
const stat = await fs.lstat(source);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Unsafe runtime source file: ${relative}`);
|
||||
if (stat.size !== file.size || await sha256File(source) !== file.sha256) {
|
||||
throw new Error(`Runtime file changed before component staging: ${relative}`);
|
||||
}
|
||||
const destination = safeJoin(path.join(output, component, "gstack"), relative);
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
if (process.platform !== "win32") await fs.chmod(destination, stat.mode & 0o777);
|
||||
summary[component].files += 1;
|
||||
summary[component].bytes += stat.size;
|
||||
}
|
||||
|
||||
for (const [component, details] of Object.entries(summary)) {
|
||||
if (details.files === 0) throw new Error(`Release component is empty: ${component}`);
|
||||
}
|
||||
await fs.writeFile(path.join(output, "components.json"), `${JSON.stringify(summary, null, 2)}\n`, { flag: "wx", mode: 0o644 });
|
||||
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
||||
|
||||
function normalizeRelative(value) {
|
||||
const normalized = path.posix.normalize(value.replaceAll("\\", "/"));
|
||||
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
|
||||
throw new Error(`Unsafe manifest path: ${value}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function safeJoin(root, relative) {
|
||||
const target = path.resolve(root, ...relative.split("/"));
|
||||
const relation = path.relative(root, target);
|
||||
if (relation === ".." || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) {
|
||||
throw new Error(`Path escaped component root: ${relative}`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function sha256File(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(file);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.once("error", reject);
|
||||
stream.once("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user