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
cilabel 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 theerrors.pypaths (core, SDK, regenerate script)migrations-gate.yml— PRs touchingmigrations/**orsrc/climate_lama/models/**(executesalembic upgrade headfrom scratch against real PostGIS + the DB-layer tests)oasis-conformance.yml— PRs touchingdocker/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 inuv.lock, or the workflow itself (builds the sidecar'sconformancestage and asserts Oasis's reference model, OasisPiWindcase_0, against its published outputs; issue #1478). A PR whose only listed change is auv.lockedit that leaves theclimate-lama-engineentry 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 therun-slow-testsPR 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 aservices:container: the server image needsserver /dataas its command, which service containers cannot pass, so the "Start MinIO" step runs it only forrun_memory_ingest=true(below).run_memory_ingest=true(workflow_dispatchonly — 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 — theINGEST_MEMORY_FIXTURE_URL/INGEST_MEMORY_FIXTURE_SHA256repository variables have never been set (gh api repos/CortoMaltese3/climate-lama/actions/variablesreturns{"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 withrun_memory_ingest=trueonly 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.