Files
James Russo 962c95406d fix(registry): verify staged asset content before publishing manifests (#3832)
* fix(registry): verify staged asset content before publishing manifests

* test(registry): run asset staging regression in script CI

* refactor(registry): separate verified manifest publication
2026-09-10 04:28:58 -04:00

274 lines
9.2 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Move a registry item's binary assets onto the public CDN.
*
* A registry item is served straight out of this repository
* (`DEFAULT_REGISTRY_URL` points at raw.githubusercontent.com), so every byte a
* block ships is a byte in the history forever. Compositions are text and cost
* nothing; images are not. One carousel family arrived with 396 JPEGs.
*
* This rewrites those files as `url` entries in the manifest and takes the
* bytes out of the tree. `path` is untouched — it still says where the file
* lands relative to the item, so the composition HTML, the installer's target
* mirroring and the catalog preview renderer all keep working unchanged.
*
* Keys are content-addressed: the same image referenced by twenty-five blocks
* uploads once and every manifest points at the same URL. It also means a
* changed image gets a new key, which is the only safe way to publish behind
* `immutable, max-age=31536000` — a re-upload under the old name would keep
* serving the old bytes at the edge until the TTL expired.
*
* Usage:
* bun run scripts/host-registry-assets.ts --all
* bun run scripts/host-registry-assets.ts carousel-orbit-1 carousel-orbit-2
* bun run scripts/host-registry-assets.ts --all --dry-run
* bun run scripts/host-registry-assets.ts --all --no-upload # stage only
*
* Requires AWS credentials for the heygen engineering account
* (profile: engineering-767398024897) with s3:PutObject, unless --no-upload.
* Contributors without AWS access: open the PR with the assets committed and a
* maintainer will run this before merging.
*/
import { createHash } from "node:crypto";
import {
copyFileSync,
constants,
closeSync,
fstatSync,
openSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { runAsCommand } from "./entrypoint.ts";
// Import from source, like every other script here: bun workspace linking does
// not resolve for scripts outside packages/.
import { ITEM_TYPE_DIRS, type FileTarget, type RegistryItem } from "../packages/core/src/index.js";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const CDN_BASE = "https://static.heygen.ai/hyperframes-oss/registry-assets";
const S3_DEST = "s3://heygen-public/hyperframes-oss/registry-assets";
const PROFILE = process.env.AWS_PROFILE ?? "engineering-767398024897";
const STAGING_DIR = resolve(repoRoot, ".registry-assets-staging");
/**
* Extensions worth hosting. Text files stay in the repository: they diff, they
* review, and they cost a few hundred bytes. Only the opaque blobs leave.
*/
const HOSTED_EXTENSIONS = new Set([
".png",
".jpg",
".jpeg",
".webp",
".gif",
".avif",
".mp4",
".webm",
".mov",
".mp3",
".wav",
".m4a",
".woff",
".woff2",
".ttf",
".otf",
".glb",
".gltf",
]);
interface Plan {
/** Absolute path of the file to remove from the tree. */
source: string;
/** Content-addressed object key, without the bucket prefix. */
key: string;
/** Full digest of the bytes used to derive the key. */
digest: string;
/** Public URL the manifest will carry. */
url: string;
}
function itemDirsUnder(typeDir: string): string[] {
const root = join(repoRoot, "registry", typeDir);
if (!existsSync(root)) return [];
return readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(root, entry.name))
.filter((dir) => existsSync(join(dir, "registry-item.json")));
}
/** Every item directory under registry/ that has a manifest. */
function allItemDirs(): string[] {
const typeDirs = [...new Set(Object.values(ITEM_TYPE_DIRS))];
return typeDirs.flatMap(itemDirsUnder).sort();
}
function itemDirFor(name: string): string {
const match = allItemDirs().find((dir) => dir.endsWith(`/${name}`));
if (match === undefined) throw new Error(`No registry item named "${name}".`);
return match;
}
/**
* A file that already carries `url` is left exactly as it is: its bytes are
* gone from the tree, so re-deriving a key from a file that is not there would
* either crash or, worse, silently drop the entry.
*/
function needsHosting(file: FileTarget): boolean {
return file.url === undefined && HOSTED_EXTENSIONS.has(extname(file.path).toLowerCase());
}
/** Point one file at the CDN, and say which bytes have to get there. */
function hostFile(itemName: string, itemDir: string, file: FileTarget): Plan {
const source = resolve(itemDir, file.path);
const fd = openSync(source, constants.O_RDONLY | constants.O_NONBLOCK);
let digest: string;
try {
if (!fstatSync(fd).isFile()) {
throw new Error(`${itemName}: files[] declares "${file.path}", which is not a file.`);
}
digest = createHash("sha256").update(readFileSync(fd)).digest("hex");
} finally {
closeSync(fd);
}
const key = `${digest.slice(0, 16)}${extname(file.path).toLowerCase()}`;
file.url = `${CDN_BASE}/${key}`;
return { source, key, digest, url: file.url };
}
/** Prepare a manifest update and identify the bytes that must reach the CDN. */
function planItem(itemDir: string): {
plans: Plan[];
manifestPath: string;
manifest: RegistryItem;
} {
const manifestPath = join(itemDir, "registry-item.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
const plans = (manifest.files ?? [])
.filter(needsHosting)
.map((file) => hostFile(manifest.name, itemDir, file));
return { plans, manifestPath, manifest };
}
function writeManifests(items: ReturnType<typeof planItem>[]): void {
for (const { manifestPath, manifest } of items) {
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
}
}
/** Collect every planned object into one flat directory, deduplicated by key. */
function stage(plans: Plan[]): number {
rmSync(STAGING_DIR, { recursive: true, force: true });
mkdirSync(STAGING_DIR, { recursive: true });
const seen = new Set<string>();
for (const plan of plans) {
if (seen.has(plan.key)) continue;
seen.add(plan.key);
const stagedPath = join(STAGING_DIR, plan.key);
copyFileSync(plan.source, stagedPath);
const digest = createHash("sha256").update(readFileSync(stagedPath)).digest("hex");
if (digest !== plan.digest) {
unlinkSync(stagedPath);
throw new Error(`Asset changed while staging: ${plan.source}. No upload was attempted.`);
}
}
return seen.size;
}
function upload(): void {
// `s3 sync` rather than `cp --recursive`: keys are content-addressed, so an
// object already up there is byte-identical and re-sending it is waste.
//
// No CloudFront invalidation, and that is not an omission — a new key has
// never been requested, so no edge holds a stale copy of it. That is the
// whole point of hashing the name.
execFileSync(
"aws",
[
"--profile",
PROFILE,
"s3",
"sync",
`${STAGING_DIR}/`,
`${S3_DEST}/`,
"--cache-control",
"public, max-age=31536000, immutable",
"--metadata-directive",
"REPLACE",
],
{ stdio: "inherit" },
);
}
/** Report what would move, changing nothing. */
function reportDryRun(itemDirs: string[]): void {
for (const dir of itemDirs) {
const { plans } = planItem(dir);
if (plans.length > 0) {
console.log(`${dir.replace(`${repoRoot}/`, "")}: ${plans.length} file(s) would be hosted`);
}
}
}
/** Drop the local copies. Only ever called once the bytes are somewhere else. */
function discardStagedSources(plans: Plan[]): void {
for (const plan of plans) {
if (existsSync(plan.source)) unlinkSync(plan.source);
}
rmSync(STAGING_DIR, { recursive: true, force: true });
}
function resolveItemDirs(argv: string[]): string[] {
if (argv.includes("--all")) return allItemDirs();
const names = argv.filter((arg) => !arg.startsWith("--"));
if (names.length === 0) throw new Error("Nothing to do: pass item names or --all.");
return names.map(itemDirFor);
}
export async function main(argv: string[]): Promise<void> {
const itemDirs = resolveItemDirs(argv);
if (argv.includes("--dry-run")) {
reportDryRun(itemDirs);
return;
}
const items = itemDirs.map(planItem).filter((item) => item.plans.length > 0);
const plans = items.flatMap((item) => item.plans);
if (plans.length === 0) {
console.log("No unhosted binary assets found — nothing to do.");
return;
}
const objects = stage(plans);
writeManifests(items);
console.log(
`Staged ${objects} object(s) from ${plans.length} manifest entr(ies) → ${STAGING_DIR}`,
);
if (argv.includes("--no-upload")) {
console.log(
`Skipping upload. Publish with:\n aws --profile ${PROFILE} s3 sync ${STAGING_DIR}/ ${S3_DEST}/ --cache-control "public, max-age=31536000, immutable" --metadata-directive REPLACE`,
);
return;
}
// Discarding before the upload succeeded would leave manifests pointing at
// URLs that 404, with no local copy to retry from.
upload();
discardStagedSources(plans);
console.log(`Published ${objects} object(s). Live under ${CDN_BASE}/`);
}
runAsCommand(import.meta.url, () => main(process.argv.slice(2)));