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 }} cli: ${{ steps.filter.outputs.cli }} skills: ${{ steps.filter.outputs.skills }} 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/**" cli: - "packages/cli/**" - "package.json" - "bun.lock" - ".github/workflows/ci.yml" skills: - "skills/**" - "skills-manifest.json" - "package.json" - ".github/workflows/ci.yml" 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 - run: corepack enable - run: corepack prepare pnpm@10.17.1 --activate - uses: ./.github/actions/prepare-ffmpeg-bin - run: bun install --frozen-lockfile - run: bun run build - run: bun run verify:packed-manifests 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 `` # 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 --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 --filter '!@hyperframes/producer' test # Tests under skills/**/*.test.mjs are bare `node --test` files with only # `node:` built-in imports. They aren't part of any workspace package, and # the main `Test` job's `code` path filter excludes `skills/**`, so without # this dedicated job they'd never run in CI. Examples: # * skills/media-use/scripts/resolve.test.mjs # * skills/media-use/scripts/lib/manifest.test.mjs # Several of these are regression guards (e.g. shell-injection cases), so # the whole point is that they fire on PRs that touch skills/. test-skills: name: "Test: skills" needs: changes if: needs.changes.outputs.skills == 'true' runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - name: Discover and run skills tests # We expand the test list via bash so the job fails loudly when the # matcher comes back empty, rather than silently no-op'ing (which # would defeat the whole point of this job). run: | set -euo pipefail mapfile -t SKILLS_TESTS < <(find skills -type f -name "*.test.mjs" | sort) if [ "${#SKILLS_TESTS[@]}" -eq 0 ]; then echo "::error::No skills/**/*.test.mjs files found. Did the layout change?" exit 1 fi printf 'Running %d skills test file(s):\n' "${#SKILLS_TESTS[@]}" printf ' * %s\n' "${SKILLS_TESTS[@]}" node --test "${SKILLS_TESTS[@]}" # Guards that skills-manifest.json (the published freshness fingerprint read # by `hyperframes skills check`) was regenerated when a skill changed. Runs # `gen:skills-manifest --check`, which compares per-skill content hashes; the # manifest carries no version/timestamp, so it only fails on real content # drift. bun runs the TS script directly, no install needed. skills-manifest: name: "Skills: manifest in sync" needs: changes if: needs.changes.outputs.skills == 'true' runs-on: ubuntu-latest timeout-minutes: 3 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - name: Verify skills-manifest.json matches skills/ run: bun packages/cli/scripts/gen-skills-manifest.ts --check cli-npx-shim: name: "CLI: npx shim (${{ matrix.os }})" needs: changes if: needs.changes.outputs.cli == 'true' runs-on: ${{ matrix.os }} timeout-minutes: 10 strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - name: Install dependencies if: runner.os != 'Windows' run: bun install --frozen-lockfile --ignore-scripts - name: Install dependencies if: runner.os == 'Windows' run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted - run: bun run --cwd packages/cli test src/utils/npxCommand.test.ts src/commands/skills.test.ts sdk-tests: name: "SDK: unit + contract + smoke" needs: changes if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - run: bun install --frozen-lockfile # Build workspace deps so the sdk's @hyperframes/parsers + core subpath # imports resolve via the "node" export condition (dist) under vitest. - run: bun run --filter '@hyperframes/parsers' build - run: bun run --cwd packages/core build - run: bun run --filter @hyperframes/sdk 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 # Build workspace deps so the studio vite.config.ts (loaded by Node) can # resolve @hyperframes/core and @hyperframes/studio-server via the "node" # export condition (dist). - 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: Start studio and check for runtime errors run: | # Start the studio Vite dev server (fast — no bundle step) 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 with API mocking to trigger # the full splash→main transition (catches hooks-after-early-return bugs) 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()); }); // Mock the project API so the studio transitions past the splash screen. // Without this, useServerConnection stays in "waiting" and the full React // tree (with all hooks) never renders — missing hooks-order violations. const COMP_HTML = '