Skip to content

CI model — on-demand

ci.yml and sdk-smoke.yml do not run automatically on every push or PR — that paid GitHub Actions' per-job 1-minute billing floor on every single change. They run only via:

  • gh workflow run ci.yml --ref main / gh workflow run sdk-smoke.yml --ref main (manual dispatch), or
  • adding the ci label to a PR (any other label-add fires the workflow but every job's guard evaluates false, so it finishes as a skipped, 0-minute run).

Consequence: PRs have no status checks by default, with exactly three path-scoped exceptions that DO fire on PRs and DO block merge when red:

  • sdk-regenerate.yml — PRs touching the errors.py paths (core, SDK, regenerate script)
  • migrations-gate.yml — PRs touching migrations/** or src/climate_lama/models/** (executes alembic upgrade head from scratch against real PostGIS + the DB-layer tests)
  • oasis-conformance.yml — PRs touching docker/oasis-sidecar/**, the Oasis parity test (tests/parity/test_parity_oasis_*.py), tests/parity/harness.py, tests/fixtures/generator.py, src/climate_lama/worker/models/engine_adapter.py, the engine pin in uv.lock, or the workflow itself (builds the sidecar's conformance stage and asserts Oasis's reference model, OasisPiWind case_0, against its published outputs; issue #1478). A PR whose only listed change is a uv.lock edit that leaves the climate-lama-engine entry alone ends green in about a minute (engine pin unchanged — conformance not needed)

None of the three is a full-suite check — migrations-gate.yml runs the DB-layer tests and the migration guards, never pytest tests/, so a green result means "this migration applies and the DB layer passes", not "this PR is verified"; ci.yml on the debounced trunk gate is still the first full-suite run, and it happens after merge (migration 0087 merged green here and broke trunk for three commits — issue #1128).

On any other path, merge on mergeable, never wait for checks to appear — an empty check rollup is the normal state, not a stall. On those three path sets, land-wait must read the rollup and wait for the gate (oasis-conformance.yml takes several minutes: an image build plus a cold reference-model run).

Declared mode for /phase-build: NO-PR-CI

This repo runs no CI on PRs outside the three path-scoped gates above — that is the declaration /phase-build reads to enter NO-PR-CI mode (its --no-pr-ci flag forces the same). In that mode land-wait treats an empty check rollup as the expected terminal state and merges on mergeable instead of stalling on checks that will never arrive — except on the three gated path sets, where the rollup is real and must be green.

Because nothing verifies an individual PR here, the trunk gate is not optional — it is the only verification in the whole run. But it is debounced, not per-wave (2026-08-01: 19 CI dispatches + 14 smokes in one 30-merge day burned 253 minutes — 12.7% of the monthly pool):

git fetch origin main && HEAD=$(git rev-parse origin/main)
# 1. DEBOUNCE: newest ci.yml run on main, ANY sha. If it is queued/running, or it is
#    green and younger than 4 hours, do NOT dispatch — later commits ride the next gate.
gh run list --workflow ci.yml --branch main -L 1 \
  --json headSha,status,conclusion,createdAt
# 2. SHA dedupe (only if the debounce passed): skip if a run for THIS sha already exists
gh run list --workflow ci.yml --branch main -L 10 \
  --json databaseId,headSha,status,conclusion --jq "[.[] | select(.headSha==\"$HEAD\")] | .[0]"
gh workflow run ci.yml --ref main        # only if both say dispatch

The debounce window is the batching mechanism: CI on HEAD covers every commit beneath it, so commits landing inside the window are verified by the next gate, not their own. Dispatch outside the window when: the 4 hours elapsed and unverified commits exist, a session is ending its last wave of the day (end-of-day sweep — never leave the trunk unverified overnight), or a tag/release needs a SHA-exact green now. A red gate implicates every commit since the last green — acceptable at this batch size, and the revert protocol below handles it.

sdk-smoke.yml is a release gate, not a merge gate. Dispatch it only when cutting a v*/sdk-v* tag (/tag drives this) or on explicit request — never per wave, and the old "dispatch when the batch touched sdk/, docker/, compose, migrations/, or worker/" rule is dead (nearly every batch touches worker/; that rule alone burned 91 min on 2026-08-01).

Never add the ci label to a PR unless the user explicitly asks — it fires both ci.yml and sdk-smoke.yml on that PR (~13 billed minutes for one PR's pre-merge check that the debounced trunk gate would have covered for free).

Recovery when the gate is red: git revert newest-first (squash merges revert one-to-one with PRs). Bisecting needs a temp branch per candidate, because workflow_dispatch takes refs, not SHAs — only worth it past ~3 suspects.

ci.yml is a single fail-fast Checks job (lint → format → mypy → docs build → tests → memory gate): one 1-minute floor and one env setup per run instead of four, and a red run stops billing at the first broken step. scripts/verify_ci_green.sh requires the job name Checks — keep them in sync. Ruff's lint and format steps cover src/ tests/ sdk/python/ scripts/ — the .py files only: the shell under scripts/ is not inspected, and scripts/ stays outside mypy and pytest (issue #1267).

docs.yml no longer deploys per push — it deploys the site once per weekday (05:00 UTC cron) plus on demand: gh workflow run docs.yml --ref main when a docs change must go live now. Docs merges landing between crons are batched into the next deploy. Because that deploy builds with mkdocs build --strict, the Checks job runs the same strict build (issue #1267), so a dead anchor turns the trunk gate red instead of silently stopping the site from updating. Both install pyproject.toml's docs extra — Checks at the versions uv.lock pins.

The opt-in slow job runs two independent gates behind two independent inputs (split in issue #1447 — before that split, run_slow=true always failed, because the ordering below made the bbox guard's own result unobservable in a green run):

  • run_slow=true (workflow_dispatch) or the run-slow-tests PR label → the issue #226 bbox index-plan guard (tests/test_api/test_viewport_bbox.py -m slow, 1M rows, Postgres only). This alone runs none of the MinIO steps, so the job normally finishes green. MinIO is not a services: container: the server image needs server /data as its command, which service containers cannot pass, so the "Start MinIO" step runs it only for run_memory_ingest=true (below).
  • run_memory_ingest=true (workflow_dispatch only — no PR-label path) → additionally provisions the MinIO fixture and runs the issue #297 memory-bound ingest gate (tests/worker/ingest/test_memory_bound.py -m slow). This currently fails — the INGEST_MEMORY_FIXTURE_URL / INGEST_MEMORY_FIXTURE_SHA256 repository variables have never been set (gh api repos/CortoMaltese3/climate-lama/actions/variables returns {"total_count": 0}) and no hosted 5 GiB fixture exists — until one is provisioned and those variables are set (see ingest-memory-test-fixture.md), dispatch with run_memory_ingest=true only to confirm the fixture pipeline, not to get a green run.

The bbox guard step runs whenever the slow job runs at all, regardless of which input triggered it — it has no if: of its own, only the job-level guard does.

Release gating — enforced, not advisory

Publishing is gated on a green CI run for the exact commit being published (ADR-046). release.yml (v* tags → GHCR images), sdk-publish-test.yml (sdk-v* tags → Test PyPI) and sdk-publish-prod.yml (→ PyPI) each run a verify-ci job first, and every publishing job is needs:-gated on it. The check runs at job level: a run whose jobs were all skipped (the non-ci label case above) does not count as tested. sdk-smoke.yml splits credential detection into its own Detect smoke credentials job so the required End-to-end smoke job is skipped — not vacuously green — when the SDK_SMOKE_* secrets are missing; if they are ever rotated out, the gate fails closed instead of degrading silently (#470). Only ever require jobs that cannot pass without doing their work.

So before tagging:

gh workflow run ci.yml --ref main
gh workflow run sdk-smoke.yml --ref main
# wait for both green, then tag that commit — the run is keyed by SHA, so if main
# moves in between, dispatch again on the new HEAD and tag that instead
scripts/verify_ci_green.sh --sha "$(git rev-parse HEAD)"   # same check, locally, 0 minutes

/tag runs that script itself and refuses to cut a tag when it fails. Cost of the gate is ~1 billed minute per publish; the failure mode it removes is publishing an untested commit to GHCR :latest or a public package index.