# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Frontend PR gate: lockfile freshness, typecheck, build, and a bundle grep # that catches the 2026.5.1 chat-history regression at the JS level. # # biome runs as non-blocking for now: the codebase currently has accumulated # ~470 errors and ~1650 warnings against the existing biome config. Surfacing # the count in CI lets us drive it down without forcing a fleet-wide cleanup # in the same PR. Drop `continue-on-error` once that number is zero. name: Frontend CI on: pull_request: paths: - 'studio/frontend/**' - 'scripts/check_frontend_dep_removal.py' - 'tests/studio/test_frontend_dep_removal.py' - 'scripts/sync_allow_scripts_pins.py' - 'tests/studio/test_sync_allow_scripts_pins.py' - '.github/workflows/studio-frontend-ci.yml' push: branches: [main, pip] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: build: name: Frontend build + bundle sanity runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: studio/frontend steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # FIXME: drop this step once @assistant-ui/* and assistant-stream # leave 0.x -- on 1.x, caret ranges are conventional. Until then, # every 0.minor on this surface is a SemVer-major (this is exactly # how 2026.5.1 shipped a broken chat runtime: ^0.12.19 quietly # resolved to 0.12.28). - name: '@assistant-ui must be pinned exactly (no caret/tilde)' working-directory: ${{ github.workspace }} run: | set -e if grep -nE '"(@assistant-ui/[a-z-]+|assistant-stream)":[[:space:]]*"[\^~]' studio/frontend/package.json; then echo "::error file=studio/frontend/package.json::These packages must be pinned to exact versions until they leave 0.x. Drop the leading ^ or ~." exit 1 fi echo "All assistant-ui packages are pinned exactly." - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' # node 22 bundles npm 10.x, which predates allowScripts. Move to the # 11.x line and fail loudly if the gate is still missing, so the # strict flag below can never silently degrade into a warning. - name: Upgrade npm to 11.x (allowScripts enforcement) working-directory: ${{ github.workspace }} run: | npm install -g npm@^11 --no-fund --no-audit V=$(npm -v) case "$V" in 11.1[6-9].*|11.[2-9][0-9].*|1[2-9].*) echo "npm $V has allowScripts" ;; *) echo "::error::npm $V lacks allowScripts (need >=11.16)"; exit 1 ;; esac # Run the structural lockfile scan BEFORE npm ci. A compromised # tarball runs its `prepare` / `postinstall` during `npm ci`, # so any catch has to fire upstream of that. The scanner is # pure-Python read-only; safe to call ahead of every install. - name: Lockfile supply-chain audit (pre-install scan) working-directory: ${{ github.workspace }} run: python3 scripts/lockfile_supply_chain_audit.py # Dependency bumps strand the version-pinned allowScripts entries. # The paired pre-commit hook auto-fixes PRs; this is the backstop. - name: allowScripts pins must match the lockfile working-directory: ${{ github.workspace }} run: | python3 tests/studio/test_sync_allow_scripts_pins.py python3 scripts/sync_allow_scripts_pins.py --check - name: Lockfile must agree with package.json (npm ci is strict) # The vite 8 chain (rolldown, lightningcss, tailwind oxide) ships napi # binaries with no install scripts. The only script-bearing deps are # covered by `allowScripts` in package.json (npm >=11.16, default in # npm 12). The pre-install lockfile audit above stays the first line # of defence -- it fires before any tarball can run code. # --strict-allow-scripts: any unreviewed install script hard-fails # the job; the sync hook keeps the pins fresh after bumps. run: npm ci --strict-allow-scripts --no-fund --no-audit - name: npm ci must not have modified the working tree working-directory: ${{ github.workspace }} run: | if ! git diff --quiet -- studio/frontend; then echo "::error::npm ci modified files; commit the updated lockfile" git status -- studio/frontend exit 1 fi # Catch the common foot-gun: a dep dropped from package.json that is # still imported somewhere. The script walks the lockfile dep graph # from the new top-level deps and only counts top-level node_modules # paths as valid resolution targets for bare src/ imports. # # actions/checkout uses fetch-depth: 1 by default, so the base branch # is not available locally. Fetch the single base commit with an # explicit refspec so origin/ is reliably created (a bare # `git fetch origin ` only updates FETCH_HEAD in some configs). - name: Dependency removal safety check if: github.event_name == 'pull_request' working-directory: ${{ github.workspace }} run: | git fetch --no-tags --depth=1 origin \ "${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}" python3 scripts/check_frontend_dep_removal.py \ --base "origin/${{ github.base_ref }}" \ --enumerate-dead python3 tests/studio/test_frontend_dep_removal.py - name: Typecheck run: npm run typecheck - name: Build run: npm run build - name: Built bundle must not contain Studio's unstable_Provider call site run: | set -e JS=$(ls dist/assets/index-*.js | head -1) HITS=$(grep -c 'unstable_Provider:' "$JS" || echo 0) echo "main bundle: $JS" echo "unstable_Provider: hits=$HITS (assistant-ui internals contribute up to 3)" if [ "$HITS" -gt 3 ]; then echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Studio bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead." exit 1 fi - name: Bundle size budget (75 MB) run: | SIZE=$(du -sb dist | cut -f1) BUDGET=$((75 * 1024 * 1024)) echo "dist size: $SIZE bytes ($((SIZE/1024/1024)) MB), budget: $BUDGET bytes (75 MB)" if [ "$SIZE" -gt "$BUDGET" ]; then echo "::error::studio/frontend/dist/ exceeded the 75 MB budget. Drop dead deps (e.g. the unused next dep) or split chunks." exit 1 fi - name: Biome (non-blocking until accumulated drift is cleared) continue-on-error: true run: npm run biome:check - name: Upload built dist # Always upload so a green run is reviewable too -- the dist # output catches "tests passed but bundle changed unexpectedly" # regressions that would be invisible if we only kept artifacts # on failure. if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: studio-frontend-dist path: studio/frontend/dist retention-days: 3