Files
hyperframes/.github/workflows/ci.yml
T
Miguel Ángel c12987e301 fix(producer): revert Proxy-based wrapTimeline to plain-object approach (#1284)
* fix(producer): revert Proxy-based wrapTimeline to plain-object approach

The `new Proxy` wrapper for GSAP timelines introduced in #1279 causes
Chrome headless to hang indefinitely during page.goto — DOMContentLoaded
never fires. The plain-object approach (explicit method allowlist) loads
in <800ms on the same composition.

The Proxy's generic get/set traps interact badly with Chrome's internal
object inspection (Symbol checks, thenable probing, DevTools serialization)
during HTML parsing, creating a permanent navigation hang. The
maybePublishRenderReady listener fix from #1279 is preserved — only the
wrapTimeline implementation is reverted.

Compositions using GSAP methods outside the allowlist (eventCallback,
labels, repeat, etc.) will see those calls silently dropped rather than
forwarded. This is the same behavior as v0.6.81 and earlier. A safer
forwarding approach can be explored separately without blocking renders.

* fix(producer): address review — stale meta.json descriptions + silently-dropped methods doc

- three-boundary: description referenced Proxy fix but the test uses
  onUpdate in to() vars (allowlist path), not eventCallback
- three-boundary-deferred: same — pins Bug 2's deferred-race, not Bug 1
- Add inline doc comment listing silently-dropped GSAP methods and the
  onUpdate workaround

* ci: add page.goto timing canary to CLI smoke test

Parse page.goto completion times from the render log and fail if the
slowest navigation exceeds 5s. Catches wrapTimeline regressions that
block DOMContentLoaded before the 60s timeout fires.

Refs: #1285

* fix(producer): forward all GSAP methods via dynamic enumeration at wrap time

Instead of silently dropping methods outside a static allowlist, enumerate
the real timeline's prototype chain at wrap time and generate plain-object
forwarding stubs for every method not already covered.

This achieves the same coverage as the `new Proxy` approach from #1279
without the Chrome headless navigation hang — no Proxy trap surfaces are
exposed to Chrome internals. Methods prefixed with `_` (GSAP private) are
skipped. All forwarded methods flush pending batch operations before
delegating, matching the existing allowlist behavior.

Closes #1285

* fix(producer): make proxy non-thenable + harden CI canary

- Skip `then` in forwardRemainingMethods — GSAP timelines are thenable
  (tl.then resolves on completion), and forwarding it makes the proxy
  thenable too: Promise.resolve(proxy) or await proxy hangs forever for
  paused timelines
- Add unit test: Promise.resolve(proxy) resolves immediately, real
  then() is never called
- CI canary: exit 1 (not 0) when no page.goto timing is found in logs,
  so a log-format change loudly breaks CI instead of silently disabling
  the canary
2026-06-08 21:10:43 -04:00

554 lines
20 KiB
YAML

name: CI
permissions:
contents: read
pull-requests: read
# Suppress hyperframes CLI telemetry from HeyGen's own CI runs.
# External users' CI continues to emit telemetry unless they set this themselves.
env:
HYPERFRAMES_NO_TELEMETRY: "1"
on:
pull_request:
# `edited` is required so the workflow re-fires when a PR's base ref is
# set back to `main` after a Graphite stack restack momentarily flips
# the base off of `main`. Without it, `pull_request` triggers are not
# re-evaluated on `base_ref_changed`, leaving required checks skipped
# for that head SHA forever.
types: [opened, synchronize, reopened, edited]
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
# Force git-based change detection instead of the pull_request REST API.
# The API path can fail the whole workflow on transient listFiles
# timeouts before any real CI work starts.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
id: filter
with:
token: ""
filters: |
code:
- "packages/**"
- "scripts/**"
- "package.json"
- "bun.lock"
- "tsconfig*.json"
- "Dockerfile*"
- ".github/workflows/**"
build:
name: Build
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bun run build
lint:
name: Lint
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bun run lint
# `fallow audit` runs dead-code + complexity + duplication analysis scoped to
# the changed files. The default `--gate new-only` means existing legacy
# findings don't fail the build — only NEW issues introduced by the PR do.
# This stops bleeding while letting incremental cleanup land separately.
#
# On findings, the job posts (or updates) a sticky comment on the PR so
# reviewers see the full list inline instead of digging through CI logs.
fallow:
name: Fallow audit
needs: changes
if: needs.changes.outputs.code == 'true' && github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
# Scope write access to this single job — the rest of `ci.yml` keeps the
# workflow-level `pull-requests: read` default so build / lint / test
# tokens can't post or modify PR comments. Job-level permissions override
# the workflow block.
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Full history so `--base origin/main` can diff against the merge
# base on stacked PRs, not just the shallow tip.
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- name: Run fallow audit
id: audit
# `bun install` above made `bunx fallow` resolve from node_modules, so
# we don't re-download fallow each run. The script disables `errexit`
# so the audit's non-zero exit (on findings) doesn't abort before we
# write the exit code to the step output. The size check guards
# against fallow crashing before producing markdown (e.g. transient
# parse failure) — without it we'd post a blank sticky comment.
run: |
set +e
bunx fallow audit --base origin/main --fail-on-issues \
--format pr-comment-github \
> /tmp/fallow-comment.md
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
if [ ! -s /tmp/fallow-comment.md ]; then
echo "fallow produced no output — see the job logs above." > /tmp/fallow-comment.md
fi
- name: Post sticky comment (findings)
if: steps.audit.outputs.exit_code != '0'
# Fork PRs run with a read-only GITHUB_TOKEN regardless of the
# workflow's `permissions:` block, so the comment post will fail on
# forks. Don't fail the whole job — the audit gate below still fires.
continue-on-error: true
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
# `header` matches fallow's built-in `<!-- fallow-id: fallow-results -->`
# sentinel so subsequent runs update the same comment.
header: fallow-results
path: /tmp/fallow-comment.md
- name: Remove stale sticky comment (clean run)
if: steps.audit.outputs.exit_code == '0'
continue-on-error: true
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: fallow-results
delete: true
- name: Fail if audit found issues
if: steps.audit.outputs.exit_code != '0'
run: |
echo "::error::Fallow audit found new issues — see the PR comment above for details."
exit 1
format:
name: Format
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bun run format:check
typecheck:
name: Typecheck
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bun run build
- run: bun run --filter '*' typecheck
test:
name: Test
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bun run test:scripts
- run: bun run --cwd packages/core build:hyperframes-runtime
- run: bun run --filter '!@hyperframes/producer' test
test-runtime-contract:
name: "Test: runtime contract"
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci
studio-load-smoke:
name: "Studio: load smoke"
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bun run --cwd packages/core build:hyperframes-runtime
- name: Start studio and check for runtime errors
run: |
# Start the studio dev server in the background
bun run --filter '@hyperframes/studio' dev -- --port 5199 &
SERVER_PID=$!
# Wait for the server to be ready (up to 20s)
for i in $(seq 1 40); do
if curl -sf http://localhost:5199/ >/dev/null 2>&1; then break; fi
sleep 0.5
done
if ! curl -sf http://localhost:5199/ >/dev/null 2>&1; then
echo "FAIL: studio dev server did not start"
kill $SERVER_PID 2>/dev/null || true
exit 1
fi
# Load the studio in headless Chrome and capture console errors
# puppeteer is a dependency of @hyperframes/producer; resolve from there
cd packages/producer
node --input-type=module <<'SMOKE_EOF'
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
const errors = [];
page.on("pageerror", (err) => errors.push(err.message));
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
await page.goto("http://localhost:5199/", { waitUntil: "networkidle0", timeout: 30000 });
await new Promise((r) => setTimeout(r, 3000));
await browser.close();
const fatal = errors.filter(
(e) => !e.includes("favicon") && !e.includes("ERR_CONNECTION_REFUSED"),
);
if (fatal.length > 0) {
console.error("FAIL: studio had runtime errors on load:");
for (const e of fatal) console.error(" •", e);
process.exit(1);
}
console.log("PASS: studio loaded without runtime errors");
SMOKE_EOF
kill $SERVER_PID 2>/dev/null || true
smoke-global-install:
name: "Smoke: global install"
needs: [changes, build]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bun run build
# Pack the CLI as a tarball (simulates what `npm publish` produces)
- name: Pack CLI tarball
run: cd packages/cli && npm pack
# Install globally using --prefix to avoid sudo
- name: Install globally via npm
run: npm install -g --prefix /tmp/hf-smoke ./packages/cli/hyperframes-cli-*.tgz
# Scaffold a blank project
- name: Init blank project
run: |
export PATH="/tmp/hf-smoke/bin:$PATH"
mkdir /tmp/hf-project && cd /tmp/hf-project
hyperframes init test-project --example blank
# Start preview, probe the runtime endpoint, assert no esbuild errors
- name: Smoke-test preview server
run: |
export PATH="/tmp/hf-smoke/bin:$PATH"
cd /tmp/hf-project/test-project
# Start the preview server in the background; capture stderr
CI=true hyperframes preview --port 3099 2>/tmp/hf-stderr.log &
SERVER_PID=$!
# Wait for the server to be ready (up to 15 s)
for i in $(seq 1 30); do
if curl -sf http://localhost:3099/ >/dev/null 2>&1; then
break
fi
sleep 0.5
done
# Probe the runtime JS endpoint
BODY=$(curl -sf http://localhost:3099/api/runtime.js | head -c 200 || true)
if [ -z "$BODY" ]; then
echo "FAIL: /api/runtime.js returned empty response"
kill $SERVER_PID 2>/dev/null || true
cat /tmp/hf-stderr.log
exit 1
fi
kill $SERVER_PID 2>/dev/null || true
wait $SERVER_PID 2>/dev/null || true
# Assert stderr does not contain esbuild / runtime load errors
if grep -qE '✘ \[ERROR\]|Failed to load runtime' /tmp/hf-stderr.log; then
echo "FAIL: preview emitted runtime errors:"
cat /tmp/hf-stderr.log
exit 1
fi
echo "PASS: global install smoke test succeeded"
cli-smoke-required:
name: "CLI smoke (required)"
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Install FFmpeg
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
- uses: ./.github/actions/prepare-ffmpeg-bin
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build monorepo
run: bun run build
- name: Create smoke input video
run: |
set -euo pipefail
ffmpeg -hide_banner -loglevel error \
-f lavfi -i testsrc2=size=640x360:rate=30 \
-f lavfi -i sine=frequency=880:sample_rate=48000 \
-t 3 \
-c:v libx264 \
-pix_fmt yuv420p \
-c:a aac \
-shortest \
-y /tmp/hf-cli-input.mp4
test -s /tmp/hf-cli-input.mp4
- name: Smoke-test CLI from monorepo source
run: |
set -euo pipefail
rm -rf /tmp/hf-cli-inside
bun run --filter @hyperframes/cli dev -- init /tmp/hf-cli-inside \
--example warm-grain \
--video /tmp/hf-cli-input.mp4 \
--skip-transcribe \
--non-interactive \
--skip-skills
bun run --filter @hyperframes/cli dev -- lint /tmp/hf-cli-inside
bun run --filter @hyperframes/cli dev -- validate /tmp/hf-cli-inside --timeout 3000
bun run --filter @hyperframes/cli dev -- render /tmp/hf-cli-inside \
--quality standard \
--workers auto \
--strict \
--output /tmp/hf-cli-inside/renders/inside.mp4 2>&1 | tee /tmp/hf-cli-render.log
test -s /tmp/hf-cli-inside/renders/inside.mp4
- name: Assert page.goto completes under 5s budget
run: |
set -euo pipefail
# Extract the longest page.goto time from render logs.
# Format: [initSession:MODE] page.goto complete (NNNms)
MAX_MS=$(grep -oP 'page\.goto complete \(\K[0-9]+' /tmp/hf-cli-render.log | sort -n | tail -1)
if [ -z "$MAX_MS" ]; then
echo "::error::No page.goto timing found in render logs — log format may have changed. Update the grep pattern."
exit 1
fi
echo "Slowest page.goto: ${MAX_MS}ms (budget: 5000ms)"
if [ "$MAX_MS" -gt 5000 ]; then
echo "::error::page.goto took ${MAX_MS}ms — exceeds 5s budget. Possible Proxy/stub regression."
exit 1
fi
- name: Pack CLI tarball
run: |
set -euo pipefail
mkdir -p /tmp/hf-cli-pack
cd packages/cli
PACKED_TARBALL="$(npm pack --pack-destination /tmp/hf-cli-pack | tail -n 1)"
test -n "$PACKED_TARBALL"
test -f "/tmp/hf-cli-pack/$PACKED_TARBALL"
echo "HF_CLI_TARBALL=/tmp/hf-cli-pack/$PACKED_TARBALL" >> "$GITHUB_ENV"
- name: Install packed CLI outside monorepo
run: |
set -euo pipefail
npm install -g --prefix /tmp/hf-cli-global "$HF_CLI_TARBALL"
- name: Smoke-test packed CLI outside monorepo
run: |
set -euo pipefail
export PATH="/tmp/hf-cli-global/bin:$PATH"
rm -rf /tmp/hf-cli-outside
hyperframes init /tmp/hf-cli-outside \
--example warm-grain \
--video /tmp/hf-cli-input.mp4 \
--skip-transcribe \
--non-interactive \
--skip-skills
hyperframes lint /tmp/hf-cli-outside
hyperframes validate /tmp/hf-cli-outside --timeout 3000
hyperframes render /tmp/hf-cli-outside \
--quality standard \
--workers auto \
--strict \
--output /tmp/hf-cli-outside/renders/outside.mp4
test -s /tmp/hf-cli-outside/renders/outside.mp4
filesize:
name: File size check
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Check file sizes (max 600 lines)
# Scoped to files THIS PR changed under packages/studio. Walking the
# whole tree blamed every unrelated PR for pre-existing offenders.
# Falls back to a full scan on push events (no base ref available)
# so the rule still guards main.
run: |
set -e
if [ -n "${{ github.base_ref }}" ]; then
mapfile -t files < <(
git diff --name-only --diff-filter=ACMR \
"origin/${{ github.base_ref }}...HEAD" -- \
'packages/studio/**/*.ts' 'packages/studio/**/*.tsx' \
| grep -vE '\.(test|spec)\.(ts|tsx)$|\.generated\.' || true
)
else
mapfile -t files < <(
find packages/studio -path '*/node_modules' -prune -o \
\( -name '*.ts' -o -name '*.tsx' \) -print \
| grep -vE '\.(test|spec)\.(ts|tsx)$|\.generated\.'
)
fi
EXIT=0
for f in "${files[@]}"; do
[ -z "$f" ] && continue
[ -f "$f" ] || continue # skip files deleted in this PR
lines=$(wc -l < "$f")
if [ "$lines" -gt 600 ]; then
echo "::error file=$f::$f has $lines lines (max 600)"
EXIT=1
fi
done
exit $EXIT
semantic-pr-title:
name: Semantic PR title
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
docs
style
refactor
perf
test
build
ci
chore
revert