name: Lint (ruff + ty) # Two things here: # 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch. # Posts a Markdown summary and a PR comment. Exit zero always. # 2. Blocking ``ruff check .`` — enforces the explicit rules in # ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge. # Separate job so the advisory diff still runs and posts even when # enforcement fails. on: workflow_call: inputs: event_name: description: The event name from the calling orchestrator (pull_request or push). type: string required: true ci_review: description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label. type: boolean default: false permissions: contents: read pull-requests: write # needed to post/update PR comments concurrency: group: lint-${{ github.ref }} cancel-in-progress: true jobs: lint-diff: name: ruff + ty diff if: inputs.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # need full history for merge-base + worktree - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 - name: Install ruff + ty uses: ./.github/actions/retry with: command: uv tool install ruff && uv tool install ty - name: Determine base ref id: base run: | # For PRs, diff against the merge base with the target branch. # For pushes to main, diff against the previous commit on main. if [ "${{ inputs.event_name }}" = "pull_request" ]; then BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD) BASE_REF="origin/${{ github.base_ref }}" else BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD) BASE_REF="HEAD~1" fi echo "sha=${BASE_SHA}" >> "$GITHUB_OUTPUT" echo "ref=${BASE_REF}" >> "$GITHUB_OUTPUT" echo "Base SHA: ${BASE_SHA}" echo "Base ref: ${BASE_REF}" - name: Run ruff + ty on HEAD run: | mkdir -p .lint-reports/head ruff check --output-format json --exit-zero \ > .lint-reports/head/ruff.json || true ty check --output-format gitlab --exit-zero \ > .lint-reports/head/ty.json || true echo "HEAD ruff: $(wc -c < .lint-reports/head/ruff.json) bytes" echo "HEAD ty: $(wc -c < .lint-reports/head/ty.json) bytes" - name: Run ruff + ty on base (via git worktree) run: | mkdir -p .lint-reports/base # Use a worktree so we don't clobber the main checkout. If the basex # SHA is identical to HEAD (e.g. first commit), skip and leave the # base reports empty — the diff script handles missing files. HEAD_SHA=$(git rev-parse HEAD) BASE_SHA="${{ steps.base.outputs.sha }}" if [ "$BASE_SHA" = "$HEAD_SHA" ]; then echo "Base SHA == HEAD SHA, skipping base scan." echo '[]' > .lint-reports/base/ruff.json echo '[]' > .lint-reports/base/ty.json else git worktree add --detach /tmp/lint-base "$BASE_SHA" ( cd /tmp/lint-base ruff check --output-format json --exit-zero \ > "$GITHUB_WORKSPACE/.lint-reports/base/ruff.json" || true ty check --output-format gitlab --exit-zero \ > "$GITHUB_WORKSPACE/.lint-reports/base/ty.json" || true ) git worktree remove --force /tmp/lint-base fi echo "base ruff: $(wc -c < .lint-reports/base/ruff.json) bytes" echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes" - name: Generate diff summary env: HEAD_REF: ${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }} run: | python scripts/lint_diff.py \ --base-ruff .lint-reports/base/ruff.json \ --head-ruff .lint-reports/head/ruff.json \ --base-ty .lint-reports/base/ty.json \ --head-ty .lint-reports/head/ty.json \ --base-ref "${{ steps.base.outputs.ref }}" \ --head-ref "$HEAD_REF" \ --output .lint-reports/summary.md cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY" ruff-blocking: # Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently # PLW1514 (unspecified-encoding) — catches bare ``open()`` / # ``read_text()`` / ``write_text()`` calls that default to locale # encoding on Windows. Failure here blocks merge; the advisory # ``lint-diff`` job above runs independently so reviewers still get # the diff comment even when enforcement fails. name: ruff enforcement (blocking) runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 - name: Install ruff uses: ./.github/actions/retry with: command: uv tool install ruff - name: ruff check . # No --exit-zero, no || true. Exit code propagates to the job, # which propagates to the required-check gate. run: | ruff check . windows-footguns: # Static guardrails on Windows-unsafe Python primitives — os.kill(pid, 0), # os.killpg, os.setsid, signal.SIGKILL without getattr fallback, # shebang scripts via subprocess, bare open() without encoding=, etc. # See scripts/check-windows-footguns.py for the full rule list. name: Windows footguns (blocking) runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: "3.11" - name: Run footgun checker run: python scripts/check-windows-footguns.py --all ci-review: # Require explicit maintainer review when CI-sensitive files change: # eslint config, workflow YAMLs, or composite actions. These files # influence what code the js-autofix job executes and pushes to # main, so a malicious PR could inject arbitrary code via a custom eslint # rule's `fix` function. The label gate ensures a human reviews before # merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml. name: CI-sensitive file review if: inputs.event_name == 'pull_request' && inputs.ci_review runs-on: ubuntu-latest timeout-minutes: 2 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Require ci-reviewed label id: label-check env: # Read-only label lookup. Use the built-in GITHUB_TOKEN (present and # read-only on forks) so the gate works on fork PRs; fall back to it # when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to # "label absent" rather than hard-failing the step. GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} run: | set -euo pipefail PR="${{ github.event.pull_request.number }}" LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true) if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then echo "reviewed=true" >> "$GITHUB_OUTPUT" echo "ci-reviewed label present." exit 0 fi echo "reviewed=false" >> "$GITHUB_OUTPUT" # On failure: find the bot's previous comment and edit it, or create # a new one if none exists. Using an HTML comment marker so we can # locate it reliably across runs without parsing the body text. # Skipped on fork PRs — GITHUB_TOKEN is read-only there, so the API # call would fail. The label gate still holds via the step below. - name: Post or update review warning if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true env: GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} run: | set -euo pipefail PR="${{ github.event.pull_request.number }}" MARKER="" BODY="${MARKER} ## ⚠️ CI-sensitive file review required This PR changes CI-sensitive files (eslint config, workflow YAMLs, or composite actions). These files influence what code the js-autofix job executes and pushes to main. A maintainer should verify: - no new eslint rules with custom \`fix\` functions that write outside linted paths, - no workflow changes that widen permissions or remove guards, - no composite action changes that alter what gets executed. After review, add the \`ci-reviewed\` label and re-run this check." # Find an existing comment with our marker. COMMENT_ID=$(gh api \ "repos/${{ github.repository }}/issues/${PR}/comments" \ --paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ | head -1 || true) if [ -n "$COMMENT_ID" ]; then gh api --method PATCH \ "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ -f body="$BODY" else gh pr comment "$PR" --body "$BODY" fi # Fail the job when the label is missing — always runs (including # fork PRs) so the security gate holds even when the comment step # was skipped above. - name: Fail on missing label if: steps.label-check.outputs.reviewed != 'true' run: | echo "::error::CI-sensitive changes require the ci-reviewed label." exit 1 # On success: if a previous warning comment exists, edit it to show # the review passed so the PR doesn't have a stale ⚠️ sitting around. # Skipped on fork PRs — no comment was ever posted to update. - name: Update previous warning to passed if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true env: GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} run: | set -euo pipefail PR="${{ github.event.pull_request.number }}" MARKER="" # Find an existing comment with our marker. COMMENT_ID=$(gh api \ "repos/${{ github.repository }}/issues/${PR}/comments" \ --paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ | head -1 || true) if [ -n "$COMMENT_ID" ]; then BODY="${MARKER} ## ✅ CI-sensitive file review passed The \`ci-reviewed\` label is present on this PR." gh api --method PATCH \ "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ -f body="$BODY" fi