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 `<!-- 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 --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

  producer-source-tests:
    name: "Producer: ${{ matrix.lane }} tests"
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        lane: [unit, integration]
    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 for integration tests
        if: matrix.lane == 'integration'
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y --no-install-recommends ffmpeg
      - uses: ./.github/actions/prepare-ffmpeg-bin
      - run: bun install --frozen-lockfile
      - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
      - run: bun run --cwd packages/core build
      - run: bun run --filter @hyperframes/engine build
      - run: bun run producer:test:${{ matrix.lane }}

  # 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
      # Runtime coverage now imports core modules that consume workspace
      # subpaths. Build their dist exports before Vitest resolves them.
      - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
      - 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)
          node scripts/studio-runtime-smoke.mjs http://localhost:5199/#project=smoke-test

          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
        # init's --skip-skills flag is neutered (see init.ts); opt out of the
        # GitHub skills check via this env so the smoke test stays offline/fast.
        env:
          HYPERFRAMES_SKIP_SKILLS: "1"
        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
        # init's --skip-skills flag is neutered (see init.ts); opt out of the
        # GitHub skills check via this env so the smoke test stays offline/fast.
        env:
          HYPERFRAMES_SKIP_SKILLS: "1"
        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
