.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