.github/workflows/ci.yml

name: CI

# Runs automatically on ready (non-draft) pull requests to main and on later
# pushes, plus manual dispatch. Documentation-only changes are skipped and a
# newer push cancels the older run.
#
# Budget: every job runs on GitHub-hosted standard runners, which GitHub does
# not bill for public repositories. The gate checks visibility on every run:
# while the repository is public it always passes. If the repository becomes
# private, or its visibility cannot be read, the cap applies: the budget gate,
# typecheck and test jobs are capped at 2, 8 and 15 minutes, and after MAX_RUNS
# executed runs in a calendar month (at most about 3750 minutes) CI jobs are
# skipped. A private repository also disables this workflow; re-enable it in
# the Actions tab the next month.
on:
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened, ready_for_review]
    paths-ignore:
      - '**/*.md'
      - 'docs/**'
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

jobs:
  budget:
    name: Budget gate
    if: github.event_name == 'workflow_dispatch' || !github.event.pull_request.draft
    runs-on: ubuntu-latest
    timeout-minutes: 2
    permissions:
      actions: write
    env:
      MAX_RUNS: 150
      GH_TOKEN: ${{ github.token }}
      REPO: ${{ github.repository }}
    outputs:
      ok: ${{ steps.count.outputs.ok }}
    steps:
      - id: count
        run: |
          visibility="$(gh api "repos/$REPO" --jq .visibility || echo unknown)"
          if [ "$visibility" = public ]; then
            echo "Public repository: hosted standard runners are not billed."
            echo "ok=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          since="$(date -u +%Y-%m-01)"
          # Count only runs that executed jobs; draft-PR runs skip every job.
          runs="$(gh api --paginate "repos/$REPO/actions/workflows/ci.yml/runs?created=>=$since&status=completed&per_page=100" --jq '[.workflow_runs[] | select(.conclusion != "skipped")] | length' | awk '{n += $1} END {print n + 0}')"
          echo "Executed CI runs this month: $runs of $MAX_RUNS (visibility: $visibility)"
          if [ "$runs" -ge "$MAX_RUNS" ]; then
            echo "ok=false" >> "$GITHUB_OUTPUT"
            echo "::warning::Monthly CI budget reached; CI jobs skipped."
            # Disable only when the repository is known to be private, so a
            # failed visibility lookup cannot switch CI off for later runs.
            if [ "$visibility" = private ]; then
              gh api -X PUT "repos/$REPO/actions/workflows/ci.yml/disable" ||
                echo "::warning::Could not disable the workflow; jobs stay skipped while the cap holds."
            fi
          else
            echo "ok=true" >> "$GITHUB_OUTPUT"
          fi

  typecheck:
    name: Typecheck (GitHub)
    needs: budget
    if: needs.budget.outputs.ok == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 8
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci --no-audit --no-fund
      - run: npx tsc --noEmit

  test:
    name: Test (GitHub)
    needs: budget
    if: needs.budget.outputs.ok == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci --no-audit --no-fund
      - run: npm test