name: static / quality on: push: branches: [main] paths-ignore: - "README.md" - "examples/**" pull_request: branches: [main] paths-ignore: - "README.md" - "examples/**" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: NODE_OPTIONS: "--max-old-space-size=4096" NX_VERBOSE_LOGGING: true NX_CI_EXECUTION_ID: ${{ github.head_ref }}-${{ github.sha }}-${{ github.run_attempt }} NX_CI_EXECUTION_ENV: "Static Quality" permissions: contents: read jobs: format: if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: write steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} # Check the head branch out from the head repo, not the base repo. # For fork PRs the head branch only exists on the fork, so defaulting # to the base repo makes checkout fail with "a branch or tag with the # name '' could not be found". Same-repo PRs resolve to the # base repo unchanged, so the auto-format push-back below still works. repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} token: ${{ secrets.GITHUB_TOKEN }} # Full history so we can diff HEAD against the current base branch # tip to scope the formatter to PR-changed files. fetch-depth: 0 - name: Setup pnpm # Omit `version:` so pnpm/action-setup inherits from the repo's # `packageManager` field in package.json (via corepack). uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20.x cache: pnpm cache-dependency-path: "**/pnpm-lock.yaml" - name: Install oxfmt # oxfmt is pinned as a root devDependency and installed from the frozen # lockfile — no ad-hoc `npm install -g`. `--ignore-scripts` keeps # install-time scripts from running against the PR-head checkout this job # uses. Put node_modules/.bin on PATH so the bare `oxfmt` calls below # (invoked via xargs) resolve the pinned binary. run: | pnpm install --frozen-lockfile --ignore-scripts echo "$(pwd)/node_modules/.bin" >> "$GITHUB_PATH" - name: Install ruff # Pin ruff so a compromised or breaking release can't land on the next # PR run with the persisted-credentials write token in this job. The # official ruff-action installs the pinned version (via uv) and puts # `ruff` on PATH for the format steps below; `args: --version` makes the # action install-only (it defaults to running `ruff check` otherwise). # Bump the version manually when needed (ruff isn't tracked by Dependabot). uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0 with: version: "0.15.13" args: "--version" - name: Collect PR-changed files for formatting if: github.event_name == 'pull_request' id: changed env: PR_BASE_REF: ${{ github.event.pull_request.base.ref }} run: | base_ref="${PR_BASE_REF}" # Fetch the current tip of the base branch so the merge-base tracks # main as it advances (using the PR's stored base.sha would pull in # every file main has touched since the PR opened). git fetch --no-tags origin "$base_ref" # Scope to files changed between the current base-branch merge-base # and HEAD so advances on main don't drag unrelated files into the # PR. Restrict to oxfmt-supported extensions so oxfmt never errors # on an unknown target. Canonical list lives upstream in oxfmt # (https://github.com/oxc-project/oxc-formatter) — update here when # oxfmt adds a new format. # # Exclude lockfiles: they match *.json / *.yaml but oxfmt rejects # them internally (size threshold or filename heuristic), which # caused lockfile-only PRs to fail with "Expected at least one # target file". Lockfiles are auto-generated by npm/pnpm and should # never be hand-formatted regardless. git diff --name-only --diff-filter=ACMR "origin/$base_ref"...HEAD -- \ '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs' \ '*.json' '*.jsonc' '*.json5' \ '*.md' \ '*.css' '*.yml' '*.yaml' '*.html' '*.vue' '*.py' \ ':!**/package-lock.json' ':!**/pnpm-lock.yaml' ':!**/yarn.lock' \ > .pr-format-files.txt : > .pr-format-files.existing.txt while IFS= read -r f; do [ -n "$f" ] && [ -f "$f" ] && printf '%s\n' "$f" >> .pr-format-files.existing.txt done < .pr-format-files.txt # Drop tracked-but-gitignored paths (e.g. fixtures under a # `recorded/` rule). Without this, oxfmt would rewrite them and # the auto-commit step's `git add` would refuse the ignored # path, killing the whole step and leaving the PR unfixed. if [ -s .pr-format-files.existing.txt ]; then git ls-files -i -c --exclude-standard > .pr-format-files.ignored.txt grep -vxFf .pr-format-files.ignored.txt .pr-format-files.existing.txt > .pr-format-files.scoped.txt || true mv .pr-format-files.scoped.txt .pr-format-files.existing.txt fi count=$(wc -l < .pr-format-files.existing.txt | tr -d ' ') echo "count=$count" >> "$GITHUB_OUTPUT" echo "PR-changed format candidates: $count" cat .pr-format-files.existing.txt - name: Run formatter (fix on PR) run: | if [ "${{ steps.changed.outputs.count }}" = "0" ]; then echo "No formattable files changed in this PR — skipping." exit 0 fi # oxfmt: auto-fix JS/TS/JSON/MD/CSS/YAML/HTML/Vue if ! xargs -a .pr-format-files.existing.txt oxfmt --no-error-on-unmatched-pattern --write; then echo "::warning::oxfmt exited with error — auto-fix may be incomplete" fi # ruff: auto-fix Python py_files=$(grep -E '\.py$' .pr-format-files.existing.txt || true) if [ -n "$py_files" ]; then echo "$py_files" | xargs ruff format || echo "::warning::ruff format exited with error" fi # Trigger the auto-commit only when one of the SCOPED PR files # actually changed. A whole-tree `git diff` here also trips on # unrelated working-tree drift (e.g. an LFS smudge on a tracked # `*.png filter=lfs` file), which would set format_fixed=true while # the scoped `git add` below stages nothing — making `git commit` # fail with "nothing to commit". Diffing only the scoped files keeps # the trigger aligned with what the commit step can actually stage. # shellcheck disable=SC2046 # intentional split: each path is a # separate `git diff` pathspec arg; the `-s` guard rules out the # empty-arg (whole-tree) case, and PR paths never contain spaces. if [ -s .pr-format-files.existing.txt ] && \ ! git diff --quiet -- $(cat .pr-format-files.existing.txt); then echo "format_fixed=true" >> "$GITHUB_ENV" fi # Check mode: verify everything is formatted xargs -a .pr-format-files.existing.txt oxfmt --no-error-on-unmatched-pattern --check if [ -n "$py_files" ]; then echo "$py_files" | xargs ruff format --check fi - name: Configure git for push if: >- env.format_fixed == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --local url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" env: TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Commit formatting fixes if: >- env.format_fixed == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name run: | if [ -z "$(git diff --name-only)" ]; then echo "No formatting changes to commit" exit 0 fi # Stage only the files the formatter was scoped to operate on. # Piping `git diff --name-only` into `git add` is unsafe: if a # tracked-but-gitignored path shows up in the diff, `git add` # aborts the whole step and the auto-fix push never lands — # leaving formatting violations on the PR branch and (post- # merge) on main. xargs -a .pr-format-files.existing.txt git add -- # Guard against an empty staged set: if the scoped `git add` staged # nothing (e.g. the whole-tree drift that set format_fixed=true lives # entirely outside the scoped files), `git commit` would exit 1 and # fail the job. Treat an empty index as a no-op instead. if git diff --cached --quiet; then echo "No scoped formatting changes to commit" exit 0 fi git commit -m "style: auto-fix formatting" git push oxlint: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - name: Setup pnpm # Omit `version:` so pnpm/action-setup inherits from the repo's # `packageManager` field in package.json (via corepack). uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Use Node.js 20 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20.x # setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache) cache: "pnpm" cache-dependency-path: "**/pnpm-lock.yaml" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run oxlint check run: pnpm run lint package-quality: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - name: Setup pnpm # Omit `version:` so pnpm/action-setup inherits from the repo's # `packageManager` field in package.json (via corepack). uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Use Node.js 20 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20.x # setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache) cache: "pnpm" cache-dependency-path: "**/pnpm-lock.yaml" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Configure Nx Cloud environment run: | { echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-quality-packages" echo "NX_CLOUD_NO_TIMEOUTS=true" echo "NX_CLOUD_DISTRIBUTED_EXECUTION=false" echo "NX_NO_CLOUD=true" } >> "$GITHUB_ENV" - name: Run publint and attw run: pnpm run check:packages check-types: runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read env: # tsc on @copilotkit/runtime needs ~10 GB: the AI SDK v6 tool() # generics explode against zod 3 schemas (~40M type instantiations, # ~5 min check time). Bounding the worst inline schemas helps but the # cost is systemic to the ai x zod type interaction, so this job gets # a 12 GB heap instead of the workflow-level 4 GB default. NODE_OPTIONS: "--max-old-space-size=12288" steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - name: Setup pnpm # Omit `version:` so pnpm/action-setup inherits from the repo's # `packageManager` field in package.json (via corepack). uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Use Node.js 20 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20.x # setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache) cache: "pnpm" cache-dependency-path: "**/pnpm-lock.yaml" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Configure Nx Cloud environment run: | { echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-quality-check-types" echo "NX_CLOUD_NO_TIMEOUTS=true" echo "NX_CLOUD_DISTRIBUTED_EXECUTION=false" echo "NX_NO_CLOUD=true" } >> "$GITHUB_ENV" - name: Generate GraphQL codegen files run: npx nx run @copilotkit/runtime-client-gql:graphql-codegen - name: Run check-types # Invoke nx directly: `pnpm run check-types -- --parallel=1` makes # nx forward --parallel=1 to each package's tsc command instead of # consuming it. --parallel=1 keeps tsc within the runner's 16 GB # RAM: the @copilotkit/runtime check alone peaks near 10 GB. run: npx nx run-many -t check-types --parallel=1 commitlint: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read pull-requests: write steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 persist-credentials: false - name: Setup pnpm # Omit `version:` so pnpm/action-setup inherits from the repo's # `packageManager` field in package.json (via corepack). uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Use Node.js 20 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20.x # setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache) cache: "pnpm" cache-dependency-path: "**/pnpm-lock.yaml" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Validate current commit (last commit) with commitlint if: github.event_name == 'push' run: | # Skip merge commits. GitHub's "Create a merge commit" option takes # the message from the PR body, which can contain markdown lists # that parse as additional (empty) commit subjects and fail # subject-empty / type-empty — see commit 5ed233f01. parents=$(git rev-list --parents -n 1 HEAD | awk '{print NF - 1}') if [ "$parents" -gt 1 ]; then echo "HEAD is a merge commit ($parents parents) — skipping commitlint." exit 0 fi npx commitlint --last --verbose - name: Validate PR commits with commitlint id: commitlint if: github.event_name == 'pull_request' continue-on-error: true env: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: npx commitlint --from "${PR_BASE_SHA}" --to "${PR_HEAD_SHA}" --verbose 2>&1 | tee /tmp/commitlint-output.txt - name: Post fix suggestion on failure if: github.event_name == 'pull_request' && steps.commitlint.outcome == 'failure' continue-on-error: true uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const fs = require('fs'); const output = fs.readFileSync('/tmp/commitlint-output.txt', 'utf8'); const body = `### ❌ Commitlint failed\n\nCommit messages must follow [Conventional Commits](https://www.conventionalcommits.org/).\n\n**Valid prefixes:** \`feat:\`, \`fix:\`, \`docs:\`, \`style:\`, \`refactor:\`, \`test:\`, \`chore:\`, \`ci:\`, \`perf:\`, \`build:\`\n\n**Example:** \`feat: add user authentication\`\n\n
Full output\n\n\`\`\`\n${output}\n\`\`\`\n
\n\nTo fix, amend your commit messages:\n\`\`\`bash\ngit rebase -i HEAD~N # N = number of commits to fix\n# Change 'pick' to 'reword' for bad commits\n\`\`\``; // Find existing comment to update const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); const existing = comments.find(c => c.body.includes('Commitlint failed')); if (existing) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body, }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body, }); } - name: Fail if commitlint failed if: github.event_name == 'pull_request' && steps.commitlint.outcome == 'failure' run: exit 1