# Continuous integration

This page explains how the main CI workflow is structured, how it decides which
jobs to run, and the docs-only fast path.

## The workflow is generated, not hand-written

The CI workflow is **generated**. The source of truth is
`.github/workflows/ci.ts`, a Deno script that builds the workflow object and
writes it to `.github/workflows/ci.generated.yml`. Never edit the `.yml`
directly; it carries a `GENERATED BY ./ci.ts -- DO NOT DIRECTLY EDIT` header.

To regenerate after changing `ci.ts`:

```bash
deno run -A .github/workflows/ci.ts
```

Other workflows in `.github/workflows/` follow the same `*.ts` →
`*.generated.yml` pattern.

## Job overview

On a pull request the workflow runs, roughly:

- **`pre-build`** — a fast gating job. It decides whether downstream jobs should
  run at all and exposes the decision through job outputs.
- **`build`** (per platform) and **`build libs`** — compile Deno and the
  `deno_core` libraries.
- **`test`** (per platform, sharded) — the spec, unit, node-compat and WPT
  suites, depending on `build`.
- **`bench`** — benchmarks.
- **`deno-core-test`** and **`deno-core-miri`** — the `deno_core` / `libs/*`
  crate tests (the latter under Miri).
- **`lint`** — formatting check, jsdoc check, and `tools/lint.js` (Rust + JS).
- **`ci-status`** — an aggregator that the branch protection rule keys on. It
  passes as long as none of its dependencies failed or were cancelled; skipped
  dependencies are fine.

## How `pre-build` gates the rest

`pre-build` runs a few cheap checks and publishes their results as outputs that
downstream jobs read in their `if:` conditions:

- `skip_build` — set when a draft PR has not opted into CI. When true, the
  build, test, lint, bench and `deno_core` jobs are all skipped.
- `skip_deno_core_test` — set when no `deno_core`-related files changed, so the
  `deno_core` crate tests can be skipped (`tools/check_deno_core_changes.js`).
- `docs_only` — set when the PR only touches the `doc/` directory
  (`tools/check_docs_only_changes.js`).

Each gated job's `if:` combines the relevant outputs, for example:

```
needs.pre_build.outputs.skip_build != 'true' &&
  needs.pre_build.outputs.docs_only != 'true'
```

## The docs-only fast path

A pull request that changes **only** files under `doc/` does not need to compile
or test anything. For those PRs CI runs the `lint` job and skips everything
else.

How it works:

1. In `pre-build`, `tools/check_docs_only_changes.js` diffs the PR against its
   base SHA. If every changed file is under `doc/` (and at least one file
   changed), it writes `docs_only=true` to the job output. An empty or failed
   diff defaults to `false`, so the full pipeline runs when in doubt.
2. The build, build-libs, test, bench, `deno-core-test` and `deno-core-miri`
   jobs each add `&& docs_only != 'true'` to their run condition, so they are
   skipped on a docs-only PR. The `test` jobs depend on `build`, so skipping
   `build` cascades to them automatically.
3. The `lint` job is intentionally **not** gated on `docs_only`. It still runs,
   which means Markdown is still formatted and linted (`deno fmt --check` covers
   `doc/`). This is the "lint and nothing else" behavior.
4. `ci-status` still runs and passes: its skipped dependencies count as neither
   failure nor cancellation, and `lint` succeeds, so the required status check
   goes green and the PR can merge.

The moment a PR also touches anything outside `doc/`, `docs_only` is `false` and
the full pipeline runs as normal.

## Changing CI behavior

When you change which jobs run or add a new gate, edit `ci.ts`, regenerate the
YAML, and commit both. The generated file is checked in so reviewers can see the
effective workflow in the diff.
