ci: generate catalog artifacts through a standing publish PR (#4337)

* ci: generate catalog artifacts through a standing publish PR

* fix(catalog): reconcile obsolete publication snapshots

* refactor(catalog): report failures after staging cleanup

* refactor(catalog): separate generation and publication decisions

* fix(catalog): unpublish deleted items with their sources

* docs(catalog): keep scaffolding guidance concise
This commit is contained in:
Miguel Ángel
2026-09-23 12:09:46 -04:00
committed by GitHub
parent df97c599ee
commit b1cf88aeba
17 changed files with 1171 additions and 327 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Publish catalog
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: catalog-publish
cancel-in-progress: false
env:
HYPERFRAMES_NO_TELEMETRY: "1"
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
- run: bun run --cwd packages/core build
- run: bun run --cwd packages/core build:hyperframes-runtime
- run: bun run check:catalog-drift
- name: Refresh the standing publish PR
run: node scripts/publish-catalog.mjs
env:
GH_TOKEN: ${{ github.token }}
+29 -7
View File
@@ -80,11 +80,12 @@ jobs:
token: ""
filters: |
catalog_index:
- "registry/registry.json"
- "registry/**"
- "registry/catalog-artifact/**"
- "skills/media-use/audio/assets/sfx/**"
- "scripts/catalog/check-artifact-coverage.ts"
docs_catalog:
- "skills/media-use/audio/assets/sfx/**"
- "docs/**"
- "registry/**"
- "scripts/*catalog*"
@@ -202,8 +203,8 @@ jobs:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun scripts/generate-registry-items.ts
- run: bun run lint
- run: bun run check:docs-catalog
# Loads every catalog payload a PR changes in headless Chrome. A static scan of the
# markup cannot see an asset a script fetches, and the docs host serves only fonts,
@@ -226,8 +227,26 @@ jobs:
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
- run: bun run --cwd packages/core build
- run: bun run --cwd packages/core build:hyperframes-runtime
- name: Keep catalog source PRs source-only
if: github.event_name == 'pull_request'
run: node scripts/check-catalog-source-pr.mjs origin/main
- name: Generate and validate catalog
env:
PUBLISH_PR: ${{ github.head_ref == 'bot/catalog-publish' }}
run: |
if [ "$PUBLISH_PR" = "true" ]; then
bun run check:catalog-drift --committed
else
bun run check:catalog-drift
fi
- run: bun run verify:catalog-payloads --changed origin/main
- run: bun run check:catalog-drift
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: catalog-index
path: |
registry/registry.json
registry/catalog-artifact/local-vectors.json
registry/catalog-artifact/local-vectors.bin
# `fallow audit` runs dead-code + complexity + duplication analysis scoped to
# the changed files. The default `--gate new-only` means existing legacy
@@ -411,6 +430,8 @@ jobs:
uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce # v1
with:
chrome-version: stable
- run: bun run generate:catalog
if: needs.changes.outputs.docs_catalog == 'true'
- run: bun run test:scripts
env:
HYPERFRAMES_BROWSER_PATH: ${{ steps.catalog-chrome.outputs.chrome-path }}
@@ -525,16 +546,17 @@ jobs:
# drift. bun runs the TS script directly, no install needed.
catalog-index-coverage:
name: "Catalog: search index covers the registry"
needs: changes
needs: [changes, catalog-payloads]
if: needs.changes.outputs.catalog_index == 'true'
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
# Comparing two name lists needs neither the embedding model nor a
# network call, so this stays a seconds-long gate. Regenerating the
# vectors does need the model, which is why CI checks rather than fixes.
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: catalog-index
path: registry
- name: Verify every searchable registry item has a vector
run: bun scripts/catalog/check-artifact-coverage.ts
+29 -31
View File
@@ -99,47 +99,45 @@ before merge, listed at the end.
2. For components: include a `demo.html`
3. Run `npx hyperframes lint` and `npx hyperframes validate` on your HTML
4. Test the install flow: `hyperframes add <name> --dir /tmp/test-project`
5. Regenerate the manifest: `npx tsx scripts/generate-registry-items.ts`
5. Generate and validate the catalog: `bun run check:catalog-drift`
`registry/registry.json` is generated from the item directories, so edit it with
that script rather than by hand. An entry added by hand survives until the next
regeneration and then disappears; entries left behind for directories that no
longer exist are worse, because `hyperframes add <name>` resolves the name and
then fails on missing files.
### Generated catalog files
### What a maintainer finishes for you
Catalog additions and edits commit only the item's source files. Do not commit
search vectors, generated docs pages, preview payloads, gallery data, or Catalog
navigation. CI builds and validates them from your sources.
Two things need assets an outside contributor is not expected to install. Open
the pull request without them and say so; neither blocks review.
Deleting or renaming an item has one exception: remove its old entry from
`registry/registry.json` in the same PR that deletes the complete item directory.
The CLI reads item files directly from main, so leaving that entry would offer an
item whose files no longer exist. Keep every other index entry and all metadata
unchanged. Do not add the new entry for a rename or an add-plus-delete PR;
automation publishes additions. CI rejects missing removals, added entries,
metadata edits, and removals whose item directories still exist.
| Thing | If you have it | If you do not |
| ----------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------- |
| The search index (`registry/catalog-artifact/`) | The pre-commit hook rebuilds and stages it | The hook skips, CI names the gap, a maintainer regenerates before merge |
| The catalog preview image | Internal contributors run `scripts/upload-docs-images.sh` | Attach the preview MP4 to the PR instead |
The search index needs a 32 MB embedding model, which is an opt-in for catalog
search rather than a build dependency. Until it is regenerated your item is
findable by word search and not by meaning, which is the same state as any item
published since a user last refreshed their copy.
### Auto-generated docs
When you add a new block or component, its documentation page is generated automatically — you don't need to write MDX by hand.
Run the codegen script after adding items:
To generate the complete catalog locally after installing dependencies and
building the workspace packages:
```bash
npx tsx scripts/generate-catalog-pages.ts
bun run generate:catalog
```
This produces:
This command downloads the pinned embedding model when needed. It updates the
registry index, vectors, payloads, pages, gallery and navigation in dependency
order. Restore generated files before committing, preserving only the required
index-entry removals when you delete item directories.
- `docs/catalog/blocks/<name>.mdx` — per-block detail page
- `docs/catalog/components/<name>.mdx` — per-component detail page
- `docs/public/catalog-index.json` — flat manifest for the catalog grid page
- Updates `docs/docs.json` navigation with the new pages
After source changes merge, automation updates one standing publication PR on
`bot/catalog-publish`, titled `chore(catalog): publish generated catalog`.
A maintainer approves any waiting workflow runs, reviews it, and merges it after
checks pass. New items become discoverable after publication; edits to existing
CLI item sources take effect on main immediately. Generated docs and search
artifacts update when the publication PR merges. Existing URLs remain unchanged.
The script wipes `docs/catalog/` before regenerating, so deleted items are automatically cleaned up.
Example manifests are authored source. To deliberately scaffold them, use
`scripts/scaffold-example-manifests.ts`; indexing never rewrites them.
Catalog preview images remain a separate workflow. Attach your preview MP4 to
the item PR if you cannot upload its hosted image.
## Pull Requests
-13
View File
@@ -17,19 +17,6 @@ pre-commit:
# enforces the same via the "Skills: manifest in sync" job). Churn-free:
# the generator rewrites only when a content hash actually changed.
run: bun packages/cli/scripts/gen-skills-manifest.ts && git add skills-manifest.json
catalog-index:
glob: "registry/*/*/registry-item.json"
# Rebuild the on-device search vectors when an item's searchable text
# changes, then re-stage them so registry/catalog-artifact/ never drifts
# from the registry (CI enforces the same via the "Catalog: search index
# covers the registry" job). Churn-free: identical inputs re-embed to
# identical bytes, so an unrelated registry edit leaves no diff.
# Exit 3 is "no embedding model here", which is the normal case for an
# outside contributor. Their commit must not be blocked over a 32 MB
# opt-in they were never asked to install; CI names the gap instead.
run: |
bun scripts/catalog/build-local-vectors.ts || { [ $? -eq 3 ] && exit 0; exit 1; }
git add registry/registry.json registry/catalog-artifact/local-vectors.json registry/catalog-artifact/local-vectors.bin
typecheck:
glob: "*.{ts,tsx}"
run: cd packages/core && bunx tsc --noEmit && cd ../studio && bunx tsc --noEmit && cd ../.. && bunx tsc --noEmit -p scripts/tsconfig.json
+3 -2
View File
@@ -34,7 +34,8 @@
"check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs",
"check:docs-snippet-motion": "node scripts/check-docs-snippet-motion.mjs",
"check:docs-catalog": "node scripts/check-docs-catalog.mjs",
"check:catalog-drift": "node scripts/check-registry-set-delta.mjs origin/main && tsx scripts/catalog-drift.ts",
"generate:catalog": "bun scripts/generate-catalog.ts",
"check:catalog-drift": "tsx scripts/catalog-drift.ts",
"verify:catalog-payloads": "tsx scripts/verify-catalog-payloads.ts",
"check:workspace-contracts": "node scripts/check-workspace-contracts.mjs",
"check:package-cycles": "node scripts/check-package-cycles.mjs",
@@ -51,7 +52,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "node --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
+36 -34
View File
@@ -1,9 +1,11 @@
// Fails when the committed docs/public/catalog, which the docs build serves as-is, differs from generator output.
import { spawnSync } from "node:child_process";
import { mkdirSync, readdirSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
// Source PRs validate generated output; the publish PR also checks its committed snapshot.
import { execFileSync } from "node:child_process";
import { cpSync, mkdirSync, readdirSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { generateCatalog } from "./generate-catalog.ts";
import { GENERATED_CATALOG_PATHS } from "./catalog-generated-paths.mjs";
import { runAsCommand } from "./entrypoint.ts";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -34,40 +36,40 @@ export function treeDifferences(generatedRoot: string, committedRoot: string): s
];
}
function generateInto(outRoot: string): number {
const run = spawnSync("npx", ["tsx", "scripts/generate-catalog-payloads.ts"], {
cwd: repoRoot,
env: { ...process.env, CATALOG_PAYLOAD_ROOT: outRoot },
stdio: ["ignore", "ignore", "inherit"],
});
return run.status ?? 1;
}
/** The catalog as `git` holds it, so a file the working tree has but a .gitignore rule keeps out cannot hide drift. */
function extractCommittedCatalog(into: string): string {
const archive = spawnSync("git", ["archive", "HEAD", "docs/public/catalog"], {
cwd: repoRoot,
maxBuffer: 2 ** 31 - 1,
});
if (archive.status !== 0) throw new Error("git archive of docs/public/catalog failed.");
mkdirSync(into, { recursive: true });
const untar = spawnSync("tar", ["-x", "-C", into], { input: archive.stdout });
if (untar.status !== 0) throw new Error("could not unpack the committed catalog.");
return join(into, "docs/public/catalog");
}
async function main(): Promise<void> {
await generateCatalog();
for (const script of [
"scripts/check-docs-catalog.mjs",
"scripts/catalog/check-artifact-coverage.ts",
])
execFileSync("bun", [script], { cwd: repoRoot, stdio: "inherit" });
if (process.argv.includes("--committed")) checkCommittedCatalog();
else
console.log("Catalog sources generate successfully; derived files belong to the publish PR.");
}
function checkCommittedCatalog(): void {
const base = mkdtempSync(join(tmpdir(), "catalog-drift-"));
const outRoot = join(base, "generated");
try {
if (generateInto(outRoot) !== 0) throw new Error("The catalog payload generator failed.");
const committedRoot = extractCommittedCatalog(join(base, "committed"));
const differences = treeDifferences(outRoot, committedRoot);
if (differences.length === 0) return console.log("docs/public/catalog matches the generator.");
const listed = differences.slice(0, MAX_LISTED).map((line) => ` ${line}`);
throw new Error(
`docs/public/catalog differs from the generator in ${differences.length} file(s):\n${listed.join("\n")}\nRegenerate with \`tsx scripts/generate-catalog-payloads.ts\` and commit the result.`,
);
const committed = join(base, "committed");
const generated = join(base, "generated");
mkdirSync(committed);
mkdirSync(generated);
const archive = execFileSync("git", ["archive", "HEAD", ...GENERATED_CATALOG_PATHS], {
cwd: repoRoot,
maxBuffer: 2 ** 31 - 1,
});
execFileSync("tar", ["-x", "-C", committed], { input: archive });
for (const path of GENERATED_CATALOG_PATHS) {
mkdirSync(dirname(join(generated, path)), { recursive: true });
cpSync(join(repoRoot, path), join(generated, path), { recursive: true });
}
const differences = treeDifferences(generated, committed);
if (differences.length > 0)
throw new Error(
`Published catalog differs from generation:\n${differences.slice(0, MAX_LISTED).join("\n")}`,
);
console.log("Committed catalog matches generation.");
} finally {
rmSync(base, { recursive: true, force: true });
}
+15
View File
@@ -0,0 +1,15 @@
export const GENERATED_CATALOG_PATHS = [
"registry/registry.json",
"registry/catalog-artifact/local-vectors.json",
"registry/catalog-artifact/local-vectors.bin",
"docs/catalog/blocks",
"docs/catalog/components",
"docs/public/catalog",
"docs/public/catalog-index.json",
"docs/snippets/catalog-gallery-data.mdx",
"docs/docs.json",
];
export function isGeneratedCatalogPath(path) {
return GENERATED_CATALOG_PATHS.some((root) => path === root || path.startsWith(`${root}/`));
}
+215
View File
@@ -0,0 +1,215 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { catalogChanges, commitBatches } from "./publish-catalog.mjs";
import { GENERATED_CATALOG_PATHS } from "./catalog-generated-paths.mjs";
import { committedCatalogOutputs } from "./check-catalog-source-pr.mjs";
test("catalog source edits reject generated commits while a publication alone is accepted", () => {
assert.deepEqual(
committedCatalogOutputs(["registry/components/new/index.html", "docs/docs.json"]),
["docs/docs.json"],
);
assert.deepEqual(committedCatalogOutputs(["docs/docs.json"]), []);
assert.deepEqual(committedCatalogOutputs(["registry/components/new/registry-item.json"]), []);
});
test("publication includes changed, new and deleted generated files and excludes source changes", (t) => {
const root = mkdtempSync(join(tmpdir(), "catalog-publish-test-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const git = (...args) => execFileSync("git", args, { cwd: root });
const write = (path, content) => {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), content);
};
git("init", "-q");
write("registry/registry.json", "old");
write("docs/public/catalog/blocks/gone.json", "old");
write("registry/blocks/source/index.html", "source");
git("add", ".");
git(
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"-qm",
"fixture",
);
write("registry/registry.json", "new");
write("registry/blocks/source/index.html", "changed source");
write("docs/public/catalog/blocks/new.json", Buffer.from([0, 255, 4]));
rmSync(join(root, "docs/public/catalog/blocks/gone.json"));
assert.deepEqual(catalogChanges(root, "HEAD"), [
{ path: "docs/public/catalog/blocks/gone.json", kind: "delete" },
{ path: "docs/public/catalog/blocks/new.json", kind: "write", contents: "AP8E" },
{ path: "registry/registry.json", kind: "write", contents: "bmV3" },
]);
});
test("signed API batches preserve deletions and limit each request to 100 changes", () => {
const changes = Array.from({ length: 101 }, (_, index) => ({
kind: "delete",
path: `docs/public/catalog/${index}.json`,
}));
const batches = commitBatches(changes);
assert.deepEqual(
batches.map((batch) => batch.deletions.length),
[100, 1],
);
assert.deepEqual(batches[1], {
additions: [],
deletions: [{ path: "docs/public/catalog/100.json" }],
});
});
test("oversized API files fail before any publication", () => {
assert.throws(
() =>
commitBatches([
{
kind: "write",
path: "docs/public/catalog/large.json",
contents: "x".repeat(10 * 1024 * 1024),
},
]),
/exceeds API batch budget/,
);
});
function publicationFixture(t, changed) {
const root = mkdtempSync(join(tmpdir(), "catalog-publication-api-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
git("init", "-q");
for (const path of GENERATED_CATALOG_PATHS) {
const target = path.includes(".") ? path : `${path}/fixture.json`;
mkdirSync(dirname(join(root, target)), { recursive: true });
writeFileSync(join(root, target), "published");
}
git("add", ".");
git(
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"-qm",
"fixture",
);
const base = git("rev-parse", "HEAD");
if (changed) writeFileSync(join(root, "registry/registry.json"), "new publication");
const executable = join(root, "gh");
writeFileSync(
executable,
`#!/usr/bin/env node
const fs = require("node:fs");
const args = process.argv.slice(2);
const endpoint = args[1];
const method = args[args.indexOf("--method") + 1];
const body = args.includes("--input") ? JSON.parse(fs.readFileSync(0, "utf8")) : undefined;
fs.appendFileSync(process.env.API_CALLS, JSON.stringify({ endpoint, method, body }) + "\\n");
if (endpoint === "graphql" || method === "DELETE") {
console.error(endpoint === "graphql" ? "commit rejected" : "cleanup rejected");
process.exit(1);
}
if (endpoint.endsWith("/ref/heads/main")) console.log(process.env.BASE);
else if (endpoint.includes("/matching-refs/")) console.log("refs/heads/bot/catalog-publish");
else if (endpoint.includes("/ref/heads/bot/catalog-publish")) console.log("a".repeat(40));
else if (endpoint.includes("/commits/")) console.log("b".repeat(40));
else if (endpoint.includes("/pulls?")) console.log("42");
`,
{ mode: 0o755 },
);
const calls = join(root, "calls.jsonl");
const run = () =>
execFileSync(
process.execPath,
[
"--input-type=module",
"-e",
`
import { publish } from ${JSON.stringify(new URL("./publish-catalog.mjs", import.meta.url).href)};
try { publish(process.env.FIXTURE_ROOT); }
catch (error) {
console.log(JSON.stringify({ message: error.message, causes: error.errors?.map(cause => cause.message) }));
process.exitCode = 1;
}
`,
],
{
encoding: "utf8",
env: {
...process.env,
PATH: `${root}:${process.env.PATH}`,
BASE: base,
API_CALLS: calls,
FIXTURE_ROOT: root,
GITHUB_REPOSITORY: "test/catalog",
GITHUB_RUN_ID: "123",
GITHUB_RUN_ATTEMPT: "1",
},
stdio: ["pipe", "pipe", "pipe"],
},
);
return {
root,
base,
run,
calls: () =>
readFileSync(calls, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line)),
};
}
test("reverting unpublished sources closes the obsolete PR and resets its branch to main", (t) => {
const fixture = publicationFixture(t, false);
fixture.run();
assert.deepEqual(
fixture.calls().filter((call) => call.method !== "GET"),
[
{ endpoint: "repos/test/catalog/pulls/42", method: "PATCH", body: { state: "closed" } },
{
endpoint: "repos/test/catalog/git/refs/heads/bot/catalog-publish",
method: "PATCH",
body: { sha: fixture.base, force: true },
},
],
);
});
test("a failed staging cleanup preserves the publication failure as well", (t) => {
const fixture = publicationFixture(t, true);
assert.throws(fixture.run, (error) => {
const result = JSON.parse(error.stdout.trim().split("\n").at(-1));
assert.equal(result.message, "Publication and staging cleanup failed.");
assert.equal(result.causes.length, 2);
assert.match(result.causes[0], /commit rejected/);
assert.match(result.causes[1], /cleanup rejected/);
return true;
});
assert.equal(fixture.calls().at(-1).method, "DELETE");
assert.equal(
fixture.calls().some((call) => call.method === "PATCH"),
false,
);
});
test("publication refuses symlinks instead of reading outside the generated artifact", (t) => {
const fixture = publicationFixture(t, false);
const artifact = join(fixture.root, "registry/registry.json");
const unrelated = join(fixture.root, "private.txt");
writeFileSync(unrelated, "must not be published");
rmSync(artifact);
symlinkSync(unrelated, artifact);
assert.throws(() => catalogChanges(fixture.root, "HEAD"), /ELOOP|symbolic link/);
});
+74
View File
@@ -0,0 +1,74 @@
import { execFileSync } from "node:child_process";
import { isDeepStrictEqual } from "node:util";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { isGeneratedCatalogPath } from "./catalog-generated-paths.mjs";
const ITEM_FILE = /^registry\/(blocks|components|examples)\/[^/]+\//;
const ITEM_MANIFEST = /^registry\/[^/]+\/[^/]+\/registry-item\.json$/;
const INDEX = "registry/registry.json";
export function committedCatalogOutputs(paths, removalOnlyIndex = false) {
if (!paths.some((path) => ITEM_FILE.test(path))) return [];
return paths.filter(isGeneratedCatalogPath).filter((path) => path !== INDEX || !removalOnlyIndex);
}
function git(...args) {
return execFileSync("git", args, { encoding: "utf8" });
}
function sourceFiles(ref) {
return git("ls-tree", "-r", "--name-only", "-z", ref, "--", "registry")
.split("\0")
.filter((path) => ITEM_FILE.test(path));
}
function removedItems(base) {
const before = sourceFiles(base);
const after = new Set(sourceFiles("HEAD"));
const remainingDirectories = new Set([...after].map((path) => ITEM_FILE.exec(path)[0]));
return before
.filter((path) => ITEM_MANIFEST.test(path) && !after.has(path))
.map((path) => {
const directory = ITEM_FILE.exec(path)[0];
if (remainingDirectories.has(directory))
throw new Error(
`Remove the complete item directory when deleting its manifest: ${directory}`,
);
return JSON.parse(git("show", `${base}:${path}`));
});
}
function checkIndexRemovals(base, indexChanged) {
const removed = new Set(removedItems(base).map((item) => `${item.type}/${item.name}`));
const before = JSON.parse(git("show", `${base}:${INDEX}`));
const after = JSON.parse(git("show", `HEAD:${INDEX}`));
const items = before.items.filter((item) => !removed.has(`${item.type}/${item.name}`));
const hasRemovals = items.length < before.items.length;
if (!isDeepStrictEqual(after, { ...before, items }))
throw new Error(
"Source PRs must remove deleted items from registry/registry.json in the same commit. " +
"Do not add entries or change index metadata; automation publishes those changes.",
);
if (indexChanged && !hasRemovals)
throw new Error(
"Only removal of entries whose item directories are deleted may edit registry/registry.json.",
);
return hasRemovals;
}
function main() {
const requestedBase = process.argv[2] ?? "origin/main";
const base = git("merge-base", requestedBase, "HEAD").trim();
const paths = git("diff", "--name-only", "-z", base, "HEAD").split("\0").filter(Boolean);
if (!paths.some((path) => ITEM_FILE.test(path))) return;
const removalOnly = checkIndexRemovals(base, paths.includes(INDEX));
const outputs = committedCatalogOutputs(paths, removalOnly);
if (outputs.length > 0)
throw new Error(
`Catalog PRs commit item sources only, with a removal-only index exception for deleted items. Restore these generated files:\n${outputs.join("\n")}`,
);
console.log("Catalog source PR contains only item sources and any required index removals.");
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
+142
View File
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
const guard = fileURLToPath(new URL("./check-catalog-source-pr.mjs", import.meta.url));
const old = { name: "old", type: "hyperframes:component" };
const kept = { name: "kept", type: "hyperframes:block" };
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), "catalog-source-pr-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
const write = (path, contents) => {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), contents);
};
const item = (directory, entry) => {
write(`registry/${directory}/${entry.name}/registry-item.json`, JSON.stringify(entry));
write(`registry/${directory}/${entry.name}/index.html`, "<div>item</div>");
};
const index = (items, revision = "published") =>
write(
"registry/registry.json",
JSON.stringify({ name: "catalog", items, vectorRevision: revision }),
);
const commit = () => {
git("add", "-A");
git(
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"-c",
"commit.gpgsign=false",
"-c",
"core.hooksPath=/dev/null",
"commit",
"-qm",
"fixture",
);
};
git("init", "-q");
item("components", old);
write("registry/components/old/assets/registry-item.json", "asset metadata");
item("blocks", kept);
index([old, kept]);
commit();
const base = git("rev-parse", "HEAD");
return {
root,
item,
index,
removeOld: () => rmSync(join(root, "registry/components/old"), { recursive: true }),
check: () => {
commit();
return spawnSync(process.execPath, [guard, base], { cwd: root, encoding: "utf8" });
},
};
}
test("a deletion removes its index entry in the same source PR", (t) => {
const repo = fixture(t);
repo.removeOld();
repo.index([kept]);
const result = repo.check();
assert.equal(result.status, 0, result.stderr);
});
test("a rename unpublishes the old name and leaves the new name for automation", (t) => {
const repo = fixture(t);
repo.removeOld();
repo.item("components", { ...old, name: "renamed" });
repo.index([kept]);
const result = repo.check();
assert.equal(result.status, 0, result.stderr);
});
test("an add-plus-delete PR carries only the old entry removal", (t) => {
const repo = fixture(t);
repo.removeOld();
repo.item("blocks", { name: "new", type: "hyperframes:block" });
repo.index([kept]);
const result = repo.check();
assert.equal(result.status, 0, result.stderr);
});
test("a deletion cannot leave a dangling index entry", (t) => {
const repo = fixture(t);
repo.removeOld();
const result = repo.check();
assert.equal(result.status, 1);
assert.match(result.stderr, /must remove deleted items/);
});
test("the removal exception cannot publish an added entry", (t) => {
const repo = fixture(t);
repo.removeOld();
const added = { name: "new", type: "hyperframes:block" };
repo.item("blocks", added);
repo.index([kept, added]);
const result = repo.check();
assert.equal(result.status, 1);
assert.match(result.stderr, /Do not add entries/);
});
test("the removal exception cannot unpublish a directory that still exists", (t) => {
const repo = fixture(t);
repo.item("blocks", { name: "new", type: "hyperframes:block" });
repo.index([kept]);
const result = repo.check();
assert.equal(result.status, 1);
assert.match(result.stderr, /must remove deleted items/);
});
test("the removal exception preserves index metadata", (t) => {
const repo = fixture(t);
repo.removeOld();
repo.index([kept], "changed");
const result = repo.check();
assert.equal(result.status, 1);
assert.match(result.stderr, /change index metadata/);
});
test("deleting only the manifest does not qualify as deleting an item directory", (t) => {
const repo = fixture(t);
rmSync(join(repo.root, "registry/components/old/registry-item.json"));
repo.index([kept]);
const result = repo.check();
assert.equal(result.status, 1);
assert.match(result.stderr, /complete item directory/);
});
test("deleting nested asset metadata does not unpublish its item", (t) => {
const repo = fixture(t);
rmSync(join(repo.root, "registry/components/old/assets/registry-item.json"));
const result = repo.check();
assert.equal(result.status, 0, result.stderr);
});
+23
View File
@@ -0,0 +1,23 @@
import { execFileSync } from "node:child_process";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { ensureLocalModel } from "../packages/cli/src/registry/localModel.ts";
import { generateRegistryManifest } from "./generate-registry-items.ts";
import { runAsCommand } from "./entrypoint.ts";
export async function generateCatalog(): Promise<void> {
const root = fileURLToPath(new URL("..", import.meta.url));
generateRegistryManifest(root);
if (!(await ensureLocalModel()))
throw new Error("Could not download the pinned catalog embedding model.");
const run = (script: string) => execFileSync("bun", [script], { cwd: root, stdio: "inherit" });
run("scripts/catalog/build-local-vectors.ts");
// Rebuild the entire payload tree so removed items and orphaned assets disappear.
rmSync(join(root, "docs/public/catalog"), { recursive: true, force: true });
run("scripts/generate-catalog-payloads.ts");
run("scripts/generate-catalog-pages.ts");
run("scripts/sync-docs-catalog.mjs");
}
runAsCommand(import.meta.url, generateCatalog);
+31
View File
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { generateRegistryManifest } from "./generate-registry-items.ts";
test("indexing preserves authored example manifests and indexes source-only additions", (t) => {
const root = mkdtempSync(join(tmpdir(), "registry-index-test-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
for (const dir of ["blocks", "components", "examples"])
mkdirSync(join(root, "registry", dir), { recursive: true });
const example = '{"name":"starter","type":"hyperframes:example","custom":"preserve me"}\n';
mkdirSync(join(root, "registry/examples/starter"));
writeFileSync(join(root, "registry/examples/starter/registry-item.json"), example);
mkdirSync(join(root, "registry/components/new"));
writeFileSync(
join(root, "registry/components/new/registry-item.json"),
'{"name":"new","type":"hyperframes:component"}',
);
generateRegistryManifest(root);
assert.equal(
readFileSync(join(root, "registry/examples/starter/registry-item.json"), "utf8"),
example,
);
const manifest = JSON.parse(readFileSync(join(root, "registry/registry.json"), "utf8"));
assert.deepEqual(manifest.items, [
{ name: "starter", type: "hyperframes:example" },
{ name: "new", type: "hyperframes:component" },
]);
});
+47 -236
View File
@@ -1,245 +1,56 @@
#!/usr/bin/env tsx
/**
* Generate registry-item.json manifests for every example in registry/examples/,
* plus the top-level registry/registry.json manifest.
*
* Reads the legacy registry/examples/templates.json (label + hint) and probes
* each example's index.html for dimensions / duration data attributes.
* Placeholder `__VIDEO_DURATION__` falls back to 10 (the init-time default).
*
* Idempotent — safe to re-run, but will overwrite any hand-edits. Intended as
* one-shot scaffolding for PR 3.
*
* Usage:
* bun run scripts/generate-registry-items.ts
* bun run scripts/generate-registry-items.ts --only warm-grain
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
import { join, relative, resolve, dirname } from "node:path";
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
ITEM_TYPE_DIRS,
type FileTarget,
type FileType,
type RegistryItem,
type RegistryManifest,
// Import from source, like every other script here: bun workspace linking
// does not resolve for scripts outside packages/, so the package name
// typechecks on a machine with a warm node_modules and fails in CI.
} from "../packages/core/src/index.js";
import { ITEM_TYPE_DIRS } from "../packages/core/src/registry/types.ts";
import { runAsCommand } from "./entrypoint.ts";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const examplesDir = resolve(repoRoot, "registry", ITEM_TYPE_DIRS["hyperframes:example"]);
const registryManifestPath = resolve(repoRoot, "registry/registry.json");
const legacyManifestPath = resolve(examplesDir, "templates.json");
const DEFAULT_DURATION_SECONDS = 10;
const PLACEHOLDER_DURATION = "__VIDEO_DURATION__";
interface LegacyTemplateEntry {
id: string;
label: string;
hint: string;
bundled: boolean;
function readIdentity(manifest: string): { name: unknown; type: unknown } {
const item: unknown = JSON.parse(readFileSync(manifest, "utf8"));
if (typeof item !== "object" || item === null)
throw new Error(`Registry identity must be an object: ${manifest}`);
return { name: Reflect.get(item, "name"), type: Reflect.get(item, "type") };
}
interface LegacyManifest {
templates: LegacyTemplateEntry[];
function directoryItems(root: string, type: string, directory: string) {
const path = join(root, "registry", directory);
return readdirSync(path, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((entry) => {
const manifest = join(path, entry.name, "registry-item.json");
if (!existsSync(manifest)) return [];
const item = readIdentity(manifest);
if (item.name !== entry.name || item.type !== type)
throw new Error(`Registry identity does not match its directory: ${manifest}`);
return [{ name: entry.name, type }];
});
}
function readLegacyManifest(): LegacyTemplateEntry[] {
try {
const raw = readFileSync(legacyManifestPath, "utf-8");
const parsed = JSON.parse(raw) as LegacyManifest;
return parsed.templates;
} catch {
// templates.json was the bootstrap source and has been deleted. Fall back
// to scanning existing registry-item.json files and reconstructing entries.
return scanExistingItems();
export function generateRegistryManifest(root: string): void {
const items = Object.entries(ITEM_TYPE_DIRS).flatMap(([type, directory]) =>
directoryItems(root, type, directory),
);
const names = new Set<string>();
for (const item of items) {
if (names.has(item.name)) throw new Error(`Duplicate registry name: ${item.name}`);
names.add(item.name);
}
writeFileSync(
join(root, "registry/registry.json"),
JSON.stringify(
{
$schema: "https://hyperframes.heygen.com/schema/registry.json",
name: "hyperframes",
homepage: "https://hyperframes.heygen.com",
items,
},
null,
2,
) + "\n",
);
console.log(`Indexed ${items.length} registry items without changing their sources.`);
}
function scanExistingItems(): LegacyTemplateEntry[] {
const entries: LegacyTemplateEntry[] = [];
for (const dir of readdirSync(examplesDir, { withFileTypes: true })) {
if (!dir.isDirectory()) continue;
const itemPath = join(examplesDir, dir.name, "registry-item.json");
try {
const item = JSON.parse(readFileSync(itemPath, "utf-8")) as RegistryItem;
entries.push({ id: item.name, label: item.title, hint: item.description, bundled: false });
} catch {
// No manifest — skip.
}
}
return entries;
}
function extractAttr(html: string, attr: string): string | undefined {
const match = new RegExp(`data-${attr}="([^"]*)"`).exec(html);
return match?.[1];
}
interface CanvasMeta {
width: number;
height: number;
duration: number;
}
function probeCanvas(exampleDir: string): CanvasMeta {
const html = readFileSync(join(exampleDir, "index.html"), "utf-8");
const width = Number(extractAttr(html, "width") ?? 1920);
const height = Number(extractAttr(html, "height") ?? 1080);
const rawDuration = extractAttr(html, "duration");
const duration =
rawDuration === undefined || rawDuration === PLACEHOLDER_DURATION
? DEFAULT_DURATION_SECONDS
: Number(rawDuration);
return { width, height, duration };
}
function fileTypeFor(path: string): FileType {
if (path.endsWith(".html")) return "hyperframes:composition";
return "hyperframes:asset";
}
/** Walk the example dir and collect every tracked file (HTML + assets). */
function collectFiles(exampleDir: string): FileTarget[] {
const files: FileTarget[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile()) {
// Skip the registry-item.json itself if it already exists from a
// prior run; we're regenerating it.
if (entry.name === "registry-item.json") continue;
const rel = relative(exampleDir, full);
files.push({ path: rel, target: rel, type: fileTypeFor(rel) });
}
}
};
walk(exampleDir);
files.sort((a, b) => a.path.localeCompare(b.path));
return files;
}
function buildItem(entry: LegacyTemplateEntry): RegistryItem {
// The `blank` template is bundled inside the CLI package; don't generate a
// manifest in registry/examples/ for it.
const exampleDir = join(examplesDir, entry.id);
const canvas = probeCanvas(exampleDir);
const files = collectFiles(exampleDir);
return {
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
name: entry.id,
type: "hyperframes:example",
title: entry.label,
description: entry.hint,
dimensions: { width: canvas.width, height: canvas.height },
duration: canvas.duration,
files,
};
}
function writeItem(item: RegistryItem): void {
if (item.type !== "hyperframes:example") return;
const out = join(examplesDir, item.name, "registry-item.json");
writeFileSync(out, JSON.stringify(item, null, 2) + "\n", "utf-8");
console.log(`wrote ${relative(repoRoot, out)}`);
}
/**
* The manifest lists every item on disk, not just the examples this script
* scaffolds. Blocks and components carry hand-authored registry-item.json
* files; scanning the tree keeps them from being dropped on regeneration
* (previously this rewrote 300+ entries down to the 8 examples).
*/
function writeRegistryManifest(): void {
const items: Array<{ name: string; type: RegistryItem["type"] }> = [];
for (const [type, dir] of Object.entries(ITEM_TYPE_DIRS) as Array<
[RegistryItem["type"], string]
>) {
const typeDir = resolve(repoRoot, "registry", dir);
let entries: string[];
try {
entries = readdirSync(typeDir);
} catch {
continue;
}
for (const name of entries.sort()) {
try {
statSync(join(typeDir, name, "registry-item.json"));
} catch {
continue;
}
items.push({ name, type });
}
}
// `catalogArtifact.revision` belongs to build-local-vectors.ts, which stamps
// it so the CLI and the coverage gate can tell whether the published vectors
// still describe this registry. This script owns the item list and nothing
// else, so it carries that field through rather than rewriting the file
// without it: dropping it makes the gate fail and the staleness check answer
// "missing" until someone rebuilds the index.
const existing = ((): { catalogArtifact?: unknown } => {
try {
return JSON.parse(readFileSync(registryManifestPath, "utf-8")) as {
catalogArtifact?: unknown;
};
} catch {
return {};
}
})();
const manifest: RegistryManifest = {
$schema: "https://hyperframes.heygen.com/schema/registry.json",
name: "hyperframes",
homepage: "https://hyperframes.heygen.com",
items,
...(existing.catalogArtifact ? { catalogArtifact: existing.catalogArtifact } : {}),
} as RegistryManifest;
writeFileSync(registryManifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
console.log(`wrote ${relative(repoRoot, registryManifestPath)} (${items.length} items)`);
}
function main(): void {
const args = process.argv.slice(2);
const onlyIdx = args.indexOf("--only");
const only = onlyIdx >= 0 ? args[onlyIdx + 1] : undefined;
const legacy = readLegacyManifest();
// Skip bundled templates (e.g. `blank`) — they live inside the CLI package,
// not under registry/examples/.
const onDisk = legacy.filter((t) => !t.bundled);
const filtered = only ? onDisk.filter((t) => t.id === only) : onDisk;
if (filtered.length === 0) {
console.error(
only
? `No example matches --only ${only}. Available: ${onDisk.map((t) => t.id).join(", ")}`
: "No examples found in registry/examples/templates.json",
);
process.exit(1);
}
for (const entry of filtered) {
const exampleDir = join(examplesDir, entry.id);
try {
statSync(exampleDir);
} catch {
console.warn(`skip ${entry.id}: directory not found at ${relative(repoRoot, exampleDir)}`);
continue;
}
writeItem(buildItem(entry));
}
// Only rewrite the top-level manifest on a full-run (not --only).
if (!only) {
writeRegistryManifest();
}
}
main();
runAsCommand(import.meta.url, async () =>
generateRegistryManifest(resolve(fileURLToPath(new URL("..", import.meta.url)))),
);
+280
View File
@@ -0,0 +1,280 @@
import { execFileSync } from "node:child_process";
import {
closeSync,
constants,
fstatSync,
mkdtempSync,
openSync,
readFileSync,
rmSync,
} from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { GENERATED_CATALOG_PATHS } from "./catalog-generated-paths.mjs";
const BRANCH = "bot/catalog-publish";
const TITLE = "chore(catalog): publish generated catalog";
const MAX_BATCH_BYTES = 10 * 1024 * 1024;
function git(root, args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
}
export function catalogChanges(root, base) {
const tracked = git(root, [
"diff",
"--name-only",
"--no-renames",
"-z",
base,
"--",
...GENERATED_CATALOG_PATHS,
]);
const untracked = git(root, [
"ls-files",
"--others",
"--exclude-standard",
"-z",
"--",
...GENERATED_CATALOG_PATHS,
]);
const removed = new Set(
git(root, [
"diff",
"--name-only",
"--diff-filter=D",
"--no-renames",
"-z",
base,
"--",
...GENERATED_CATALOG_PATHS,
]).split("\0"),
);
return [...new Set([...tracked.split("\0"), ...untracked.split("\0")])]
.filter(Boolean)
.sort()
.map((path) => {
if (removed.has(path)) return { path, kind: "delete" };
return { path, kind: "write", contents: artifactContents(resolve(root, path)) };
});
}
function artifactContents(path) {
const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
if (!fstatSync(descriptor).isFile())
throw new Error(`Generated artifact must be a regular file: ${path}`);
return readFileSync(descriptor).toString("base64");
} finally {
closeSync(descriptor);
}
}
function batchEntry(change) {
const size = Buffer.byteLength(JSON.stringify(change));
if (size > MAX_BATCH_BYTES)
throw new Error(`Generated artifact exceeds API batch budget: ${change.path}`);
if (change.kind === "delete") return { size, field: "deletions", file: { path: change.path } };
return { size, field: "additions", file: { path: change.path, contents: change.contents } };
}
export function commitBatches(changes) {
let batch = { additions: [], deletions: [] };
const batches = [batch];
let bytes = 0;
for (const change of changes) {
const { size, field, file } = batchEntry(change);
if (bytes + size > MAX_BATCH_BYTES || batch.additions.length + batch.deletions.length === 100) {
batch = { additions: [], deletions: [] };
batches.push(batch);
bytes = 0;
}
batch[field].push(file);
bytes += size;
}
return batches.filter((entry) => entry.additions.length + entry.deletions.length > 0);
}
function api(endpoint, method = "GET", body, jq) {
const args = ["api", endpoint, "--method", method];
if (body !== undefined) args.push("--input", "-");
if (jq) args.push("--jq", jq);
return execFileSync("gh", args, {
input: body === undefined ? undefined : JSON.stringify(body),
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout: 60_000,
}).trim();
}
function commitOid(value) {
if (!/^[a-f0-9]{40}$/.test(value)) throw new Error("GitHub returned an invalid commit id.");
return value;
}
function catalogTree(root) {
const temp = mkdtempSync(join(tmpdir(), "catalog-index-"));
const run = (args) =>
execFileSync("git", args, {
cwd: root,
encoding: "utf8",
env: { ...process.env, GIT_INDEX_FILE: join(temp, "index") },
}).trim();
try {
run(["read-tree", "HEAD"]);
run(["add", "-A", "--", ...GENERATED_CATALOG_PATHS]);
return run(["write-tree"]);
} finally {
rmSync(temp, { recursive: true, force: true });
}
}
function signedCommit(repository, branch, head, fileChanges) {
const query = `mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) { commit { oid signature { isValid } } }
}`;
const result = api(
"graphql",
"POST",
{
query,
variables: {
input: {
branch: { repositoryNameWithOwner: repository, branchName: branch },
expectedHeadOid: head,
message: { headline: TITLE },
fileChanges,
},
},
},
'.data.createCommitOnBranch.commit | if .signature.isValid then .oid else error("Commit is not signed") end',
);
return commitOid(result);
}
function openPublishPrNumber(repository) {
const owner = repository.split("/")[0];
const endpoint = `repos/${repository}/pulls`;
const numbers = api(
`${endpoint}?state=open&head=${owner}:${BRANCH}&base=main`,
"GET",
undefined,
".[].number",
)
.split("\n")
.filter(Boolean);
if (numbers.length > 1) throw new Error("More than one catalog publish PR is open.");
const number = numbers[0];
if (number !== undefined && !/^\d+$/.test(number))
throw new Error("GitHub returned an invalid PR number.");
return number;
}
function openPublishPr(repository, base) {
const endpoint = `repos/${repository}/pulls`;
const number = openPublishPrNumber(repository);
const body =
`Generated catalog snapshot from ${base}.\n\n` +
"Item sources are reviewed in their own PRs. This PR publishes the registry index, search vectors, docs pages, payloads and navigation together.\n\n" +
"Approve any GitHub Actions runs awaiting approval, then review and merge this PR after checks pass. Publication uses GITHUB_TOKEN and GitHub-signed API commits; branch protection remains in effect.";
if (number !== undefined) {
if (api(`${endpoint}/${number}`, "GET", undefined, ".body") !== body)
api(`${endpoint}/${number}`, "PATCH", { title: TITLE, body });
} else {
api(endpoint, "POST", { title: TITLE, body, head: BRANCH, base: "main" });
}
}
function actionEnvironment() {
const { GITHUB_REPOSITORY: repository = "", GITHUB_RUN_ID: run = "" } = process.env;
if (!/^[\w.-]+\/[\w.-]+$/.test(repository) || !/^\d+$/.test(run))
throw new Error("Run publication in GitHub Actions.");
return { repository, run, endpoint: `repos/${repository}/git` };
}
function assertCurrentMain(endpoint, base) {
const current = commitOid(api(`${endpoint}/ref/heads/main`, "GET", undefined, ".object.sha"));
if (current !== base)
throw new Error("Main advanced during publication; the next push run will regenerate it.");
}
function updateStandingBranch(endpoint, exists, head) {
if (exists) api(`${endpoint}/refs/heads/${BRANCH}`, "PATCH", { sha: head, force: true });
else api(`${endpoint}/refs`, "POST", { ref: `refs/heads/${BRANCH}`, sha: head });
}
function clearObsoletePublication(repository, base, exists) {
const endpoint = `repos/${repository}/git`;
const number = openPublishPrNumber(repository);
assertCurrentMain(endpoint, base);
if (number !== undefined)
api(`repos/${repository}/pulls/${number}`, "PATCH", { state: "closed" });
if (exists) updateStandingBranch(endpoint, true, base);
console.log("No unpublished catalog changes; obsolete publication cleared.");
}
function snapshotMatches(root, endpoint, exists) {
if (!exists) return false;
const previous = commitOid(
api(`${endpoint}/ref/heads/${BRANCH}`, "GET", undefined, ".object.sha"),
);
const tree = commitOid(api(`${endpoint}/commits/${previous}`, "GET", undefined, ".tree.sha"));
return tree === catalogTree(root);
}
function cleanStagingBranch(endpoint, staging, errors) {
try {
api(`${endpoint}/refs/heads/${staging}`, "DELETE");
} catch (error) {
errors.push(error);
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1)
throw new AggregateError(errors, "Publication and staging cleanup failed.");
}
function publishSnapshot(context, base, exists, batches) {
const { repository, run, endpoint } = context;
const staging = `${BRANCH}-build-${run}-${process.env.GITHUB_RUN_ATTEMPT ?? "1"}`;
api(`${endpoint}/refs`, "POST", { ref: `refs/heads/${staging}`, sha: base });
const errors = [];
try {
let head = base;
for (const batch of batches) head = signedCommit(repository, staging, head, batch);
assertCurrentMain(endpoint, base);
updateStandingBranch(endpoint, exists, head);
openPublishPr(repository, base);
} catch (error) {
errors.push(error);
}
cleanStagingBranch(endpoint, staging, errors);
}
export function publish(root) {
const base = commitOid(git(root, ["rev-parse", "HEAD"]));
const changes = catalogChanges(root, base);
const batches = commitBatches(changes);
console.log(
`Catalog publication: ${changes.length} files, ${batches.length} signed commit batches.`,
);
if (process.argv.includes("--dry-run")) return;
const context = actionEnvironment();
const { repository, endpoint } = context;
assertCurrentMain(endpoint, base);
const refs = api(`${endpoint}/matching-refs/heads/${BRANCH}`, "GET", undefined, ".[].ref").split(
"\n",
);
const exists = refs.includes(`refs/heads/${BRANCH}`);
if (batches.length === 0) return clearObsoletePublication(repository, base, exists);
if (snapshotMatches(root, endpoint, exists)) {
openPublishPr(repository, base);
console.log("Standing catalog PR already contains this snapshot.");
return;
}
publishSnapshot(context, base, exists, batches);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
publish(fileURLToPath(new URL("..", import.meta.url)));
}
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env tsx
// Explicit scaffolding overwrites authored example manifests; indexing never runs it.
// Usage: bun scripts/scaffold-example-manifests.ts [--only warm-grain]
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
import { join, relative, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import {
ITEM_TYPE_DIRS,
type FileTarget,
type FileType,
type RegistryItem,
// Import from source, like every other script here: bun workspace linking
// does not resolve for scripts outside packages/, so the package name
// typechecks on a machine with a warm node_modules and fails in CI.
} from "../packages/core/src/index.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const examplesDir = resolve(repoRoot, "registry", ITEM_TYPE_DIRS["hyperframes:example"]);
const legacyManifestPath = resolve(examplesDir, "templates.json");
const DEFAULT_DURATION_SECONDS = 10;
const PLACEHOLDER_DURATION = "__VIDEO_DURATION__";
interface LegacyTemplateEntry {
id: string;
label: string;
hint: string;
bundled: boolean;
}
interface LegacyManifest {
templates: LegacyTemplateEntry[];
}
function readLegacyManifest(): LegacyTemplateEntry[] {
try {
const raw = readFileSync(legacyManifestPath, "utf-8");
const parsed = JSON.parse(raw) as LegacyManifest;
return parsed.templates;
} catch {
// templates.json was the bootstrap source and has been deleted. Fall back
// to scanning existing registry-item.json files and reconstructing entries.
return scanExistingItems();
}
}
function scanExistingItems(): LegacyTemplateEntry[] {
const entries: LegacyTemplateEntry[] = [];
for (const dir of readdirSync(examplesDir, { withFileTypes: true })) {
if (!dir.isDirectory()) continue;
const itemPath = join(examplesDir, dir.name, "registry-item.json");
try {
const item = JSON.parse(readFileSync(itemPath, "utf-8")) as RegistryItem;
entries.push({ id: item.name, label: item.title, hint: item.description, bundled: false });
} catch {
// No manifest — skip.
}
}
return entries;
}
function extractAttr(html: string, attr: string): string | undefined {
const match = new RegExp(`data-${attr}="([^"]*)"`).exec(html);
return match?.[1];
}
interface CanvasMeta {
width: number;
height: number;
duration: number;
}
function durationSeconds(rawDuration: string | undefined): number {
return rawDuration === undefined || rawDuration === PLACEHOLDER_DURATION
? DEFAULT_DURATION_SECONDS
: Number(rawDuration);
}
function probeCanvas(exampleDir: string): CanvasMeta {
const html = readFileSync(join(exampleDir, "index.html"), "utf-8");
const width = Number(extractAttr(html, "width") ?? 1920);
const height = Number(extractAttr(html, "height") ?? 1080);
const rawDuration = extractAttr(html, "duration");
const duration = durationSeconds(rawDuration);
return { width, height, duration };
}
function fileTypeFor(path: string): FileType {
if (path.endsWith(".html")) return "hyperframes:composition";
return "hyperframes:asset";
}
/** Walk the example dir and collect every tracked file (HTML + assets). */
function collectFiles(exampleDir: string): FileTarget[] {
return readdirSync(exampleDir, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name !== "registry-item.json")
.map((entry) => {
const path = relative(exampleDir, join(entry.parentPath, entry.name));
return { path, target: path, type: fileTypeFor(path) };
})
.sort((a, b) => a.path.localeCompare(b.path));
}
function buildItem(entry: LegacyTemplateEntry): RegistryItem {
// The `blank` template is bundled inside the CLI package; don't generate a
// manifest in registry/examples/ for it.
const exampleDir = join(examplesDir, entry.id);
const canvas = probeCanvas(exampleDir);
const files = collectFiles(exampleDir);
return {
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
name: entry.id,
type: "hyperframes:example",
title: entry.label,
description: entry.hint,
dimensions: { width: canvas.width, height: canvas.height },
duration: canvas.duration,
files,
};
}
function writeItem(item: RegistryItem): void {
if (item.type !== "hyperframes:example") return;
const out = join(examplesDir, item.name, "registry-item.json");
writeFileSync(out, JSON.stringify(item, null, 2) + "\n", "utf-8");
console.log(`wrote ${relative(repoRoot, out)}`);
}
function selectedExamples(): LegacyTemplateEntry[] {
const args = process.argv.slice(2);
const onlyIdx = args.indexOf("--only");
const only = onlyIdx >= 0 ? args[onlyIdx + 1] : undefined;
const onDisk = readLegacyManifest().filter((entry) => !entry.bundled);
if (!only) return onDisk;
const filtered = onDisk.filter((entry) => entry.id === only);
if (filtered.length === 0)
throw new Error(
`No example matches --only ${only}. Available: ${onDisk.map((entry) => entry.id).join(", ")}`,
);
return filtered;
}
function scaffoldExample(entry: LegacyTemplateEntry): void {
const exampleDir = join(examplesDir, entry.id);
try {
statSync(exampleDir);
} catch {
console.warn(`skip ${entry.id}: directory not found at ${relative(repoRoot, exampleDir)}`);
return;
}
writeItem(buildItem(entry));
}
function main(): void {
const entries = selectedExamples();
if (entries.length === 0)
throw new Error("No examples found in registry/examples/templates.json");
for (const entry of entries) scaffoldExample(entry);
}
main();
+30
View File
@@ -1,6 +1,11 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
changedItems,
declaresWebgpu,
itemsFromDiff,
withoutAbortedMedia,
@@ -88,3 +93,28 @@ describe("withoutAbortedMedia", () => {
assert.deepEqual(withoutAbortedMedia([aborted, ...kept]), kept);
});
});
it("browser selection includes regenerated edits and new untracked payloads", (t) => {
const root = mkdtempSync(join(tmpdir(), "catalog-selection-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const git = (...args: string[]) => execFileSync("git", args, { cwd: root });
mkdirSync(join(root, "docs/public/catalog/blocks"), { recursive: true });
const old = join(root, "docs/public/catalog/blocks/old.json");
writeFileSync(old, "{}");
git("init", "-q");
git("add", ".");
git(
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"-qm",
"fixture",
);
writeFileSync(old, '{"html":"updated"}');
writeFileSync(join(root, "docs/public/catalog/blocks/new.json"), "{}");
assert.deepEqual([...changedItems("HEAD", root)].sort(), ["new", "old"]);
});
+12 -4
View File
@@ -42,13 +42,21 @@ export function itemsFromDiff(diffOutput: string): Set<string> {
return items;
}
function changedItems(ref: string): Set<string> {
export function changedItems(ref: string, cwd = join(scriptDir, "..")): Set<string> {
const out = execFileSync(
"git",
["diff", "--name-only", "--diff-filter=ACMR", `${ref}...HEAD`, "--", "docs/public/catalog"],
{ encoding: "utf-8", cwd: join(scriptDir, "..") },
["diff", "--name-only", "--diff-filter=ACMR", ref, "--", "docs/public/catalog"],
{ encoding: "utf-8", cwd },
);
return itemsFromDiff(out);
const added = execFileSync(
"git",
["ls-files", "--others", "--exclude-standard", "--", "docs/public/catalog"],
{
encoding: "utf-8",
cwd,
},
);
return itemsFromDiff(out + "\n" + added);
}
function payloadFiles(