# CI/CD Workflows
## Overview
The `workflows/` directory contains GitHub Actions workflows that automate the continuous integration and delivery pipeline for the Research Project Template. These workflows ensure code quality, test reliability, and compatibility across environments.
## Directory Structure
```mermaid
flowchart LR
W[.github/workflows/]
W --> META[AGENTS.md · README.md]
W --> CI[ci.yml
16 jobs — 2 conditional via detect-job outputs — fep-lean, setup-hook-windows-smoke — plus 1 scheduled-only — public-matrix-receipt]
W --> STALE[stale.yml
Auto-label/close stale issues/PRs]
W --> REL[release.yml
Create GitHub Releases on version tags]
W --> DA[dependabot-automerge.yml
Auto-merge safe Dependabot PRs]
classDef d fill:#0f172a,stroke:#0f172a,color:#fff
classDef code fill:#1e3a8a,stroke:#0f172a,color:#fff
classDef doc fill:#0f766e,stroke:#0f172a,color:#fff
class W d
class CI,STALE,REL code
class META doc
```
## CI Pipeline (`ci.yml`)
### Triggers
| Trigger | Condition |
|---|---|
| `push` | Commits to `main` |
| `pull_request` | PRs targeting `main` |
| `schedule` | Weekly Sunday midnight UTC (CVE catch-up) |
| `workflow_dispatch` | Manual trigger (no inputs) |
**Concurrency:** `cancel-in-progress: true` — stale runs are cancelled automatically when a new commit is pushed.
**Global env:** `UV_FROZEN=true`, `MPLBACKEND=Agg` (non-interactive matplotlib backend).
### Job Graph
`health` depends on **`lint`** only and is blocking. `validate`, `security`, and `docs-lint` depend on **`lint` only** (parallel with the `verify-no-mocks` subtree). `setup-hook-windows-smoke` depends on **`verify-no-mocks`** and **`detect`** and is **skipped** unless `needs.detect.outputs.setup_hook == 'true'`. `test-infra`, `test-regression`, `test-project`, and `fep-lean` depend on **`verify-no-mocks`**.
```mermaid
flowchart TB
DET[detect
optional-project outputs]
DETP[detect-projects
validated capability matrix output]
ACTLINT[actionlint
lints workflow YAML · standalone]
LINT[lint] --> HEALTH[health
unified JSON artefact]
VNM[verify-no-mocks
parallel with lint · no needs:]
LINT --> VAL[validate]
LINT --> SEC[security]
LINT --> DL[docs-lint
mermaid + cross-links + consistency
installs mmdc + chrome-headless-shell]
VNM --> SHW[setup-hook-windows-smoke
skipped if no setup_hook.py]
VNM --> TI[test-infra
matrix: ubuntu × 3.10/3.11/3.12/3.13 + macOS × 3.12
codecov on 3.12/ubuntu only]
VNM --> TR[test-regression
claim-binding pins · tests/regression/]
VNM --> TP[test-project
capability manifest roster × canonical Python versions
stage_01_test.py --project per cell]
VNM --> FL[fep-lean
ubuntu-only · skipped if no lean-toolchain]
DET --> SHW
DET --> FL
DETP --> TP
TI --> PERF[performance]
TP --> PERF
classDef gate fill:#1e3a8a,stroke:#0f172a,color:#fff
classDef matrix fill:#0f766e,stroke:#0f172a,color:#fff
classDef terminal fill:#7c2d12,stroke:#0f172a,color:#fff
classDef info fill:#334155,stroke:#0f172a,color:#fff
class DET,DETP,LINT,VNM gate
class TI,TR,TP,FL,SHW matrix
class VAL,SEC,DL,PERF,ACTLINT terminal
class HEALTH info
```
### Shared setup — local composite actions
Jobs that need Python share one local composite action,
[`.github/actions/setup-python-env`](../actions/setup-python-env/action.yml),
which runs `astral-sh/setup-uv` (with `uv.lock` cache) + `actions/setup-python`.
The pinned action SHAs and cache config live there once instead of being
copy-pasted per job. Usage (checkout **must** come first — a local action's
files only exist after checkout):
```yaml
steps:
- uses: actions/checkout@
- uses: ./.github/actions/setup-python-env # defaults to Python 3.12
# with: { python-version: ${{ matrix.python-version }} } # matrix jobs only
- run: uv sync # per-job groups stay explicit
```
`detect` and `actionlint` are checkout-only and do not use it. Each job keeps
its own `uv sync …` line so optional groups (`rendering`/`monitoring`/`discopy`)
remain visible per job. When bumping a setup action's SHA, edit the composite
action — not each job.
The `health` and `docs-lint` jobs additionally share
[`.github/actions/setup-docs-lint`](../actions/setup-docs-lint/action.yml),
which provisions the pinned Node runtime/cache plus real `mmdc` and
`chrome-headless-shell`. This keeps unified health's `docs-lint` constituent
behaviorally equivalent to the dedicated documentation job.
### Job Details
#### 1. Lint & Type Check (`lint`)
- **Runner:** `ubuntu-latest` / Python 3.12
- **Tools:** `uv run ruff check`, `uv run ruff format --check`, `uv run mypy`, `uv run python -m infrastructure.skills check-all-exports`, `uv run python scripts/audit/check_template_drift.py --strict`
- **Scope:** Ruff uses public lint paths from `infrastructure.project.public_scope lint-paths`; mypy uses its narrower `source-paths` output.
#### 2. Static Health Report (`health`)
- **Runner:** `ubuntu-latest` / Python 3.12
- **Depends on:** `lint`
- **Purpose:** Runs `uv run python -m infrastructure.core.health --json --quiet` → `health-report.json`; every represented static gate blocks, while behavioral and platform matrices remain separate jobs.
#### 3. Verify No Mocks Policy (`verify-no-mocks`)
- **Runner:** `ubuntu-latest` / Python 3.12
- **Script:** [`scripts/audit/verify_no_mocks.py`](../../scripts/audit/verify_no_mocks.py) (repository root)
- **Enforced policy:** no configured prohibited mock-framework imports/calls
(`MagicMock`, `mocker.patch`, `unittest.mock`, and related lexical forms) in
test files.
- **Boundary:** `--inventory` separately classifies permitted environment
isolation and semantic dependency replacement. CI enforces a zero ceiling
for dependency replacements; environment isolation remains permitted.
#### 3b. Setup hook — Windows smoke (`setup-hook-windows-smoke`)
- **Runner:** `windows-latest` / Python 3.12
- **Depends on:** `verify-no-mocks`
- **Conditional:** `if: needs.detect.outputs.setup_hook == 'true'` — no-op skip when no project ships [`infrastructure.project.setup_hook`](../../infrastructure/project/setup_hook.py). The `detect` job computes this because job-level `hashFiles()` is invalid in GitHub Actions.
- **Step:** `uv run pytest tests/infra_tests/project/test_setup_hook.py` with `PYTHONUTF8=1`
#### 4. Infrastructure Tests (`test-infra`)
- **Matrix:** `ubuntu-latest` × `3.10`, `3.11`, `3.12`, `3.13`, plus an `include:` of `macos-latest` × `3.12` (5 cells). macOS legs are ~10x cost and rarely surface OS-specific breakage beyond the 3.12 cell, so only the 3.12 smoke runs there.
- **Coverage threshold:** 60% (`--cov-fail-under=60`)
- **Coverage file:** `.coverage.infra` (isolated from project coverage)
- **Exclusions:** Tests marked `requires_ollama` are skipped (`-m "not requires_ollama"`)
- **Codecov upload:** On Python 3.12 / ubuntu-latest only to avoid duplicate reports
#### 4b. Regression Tier — claim-binding pins (`test-regression`)
- **Depends on:** `verify-no-mocks`, `timeout-minutes: 20`, ubuntu-only.
- **Sync:** `uv sync --group public-exemplars`.
- **What it runs:** `uv run pytest tests/regression/ -q --no-cov --timeout=120`, serial (no `-n auto`) — see [`docs/maintenance/regression-testing.md`](../../docs/maintenance/regression-testing.md) for why (exemplars ship colliding top-level `src` packages resolved via per-project aliases + temporary `sys.meta_path` finders whose isolation is collection-order-sensitive).
- **Exit-code tolerance:** exit `5` (no tests collected on a clean scaffold) is treated as success so a future empty tier doesn't hard-fail the build; any real failure (exit `1`) still fails the job. A separate "Assert regression tier is not empty" step fails the job when fewer than 3 tests collect, so the claim-binding pins cannot silently vanish behind the tolerance.
#### 5. Project Tests (`test-project`)
- **Sync:** `uv sync --group public-exemplars` — the same deterministic dependency union as a fresh local `uv sync`, including the DisCoPy, monitoring, scientific, LLM-client, and PPTX groups used by the public roster. **Hypothesis** comes from the **dev** group (see root `pyproject.toml` `[dependency-groups]` and `default-groups`).
- **Matrix:** **Per-project split** — the `detect-projects` job runs `scripts/gates/public_capabilities.py --ci-matrix-json`, which validates unique normalized package identities, full-minor Python compatibility, source/test syntax, format declarations, compiled/confined direct hydration, analysis declarations, reason-bearing skips, exact roster membership, and exact matrix parity before emitting the canonical `project × Python` include list. The current source of truth yields 24 exemplars × Python 3.10/3.12 = **48 matrix cells** on `ubuntu-latest`; no project or Python literal is duplicated in workflow YAML. Both matrix jobs set `UV_PYTHON` and assert the selected runtime minor so the repository `.python-version` cannot override a matrix cell. Job `timeout-minutes: 60`.
- **Coverage threshold:** Each job enforces **that project's own ≥ 90%** floor on its `src/` (per CLAUDE.md). There is **no longer** a combined-union run or `--cov-append` — every project is isolated in its own job, which also removes the old `code_project`/`fep_lean` conftest plugin-name collision.
- **Coverage file:** `.coverage.project` (isolated; removed at the start of each job before the run)
- **Scope:** [`scripts/pipeline/stage_01_test.py`](../../scripts/pipeline/stage_01_test.py) `--project --project-only --include-slow` (one invocation per matrix cell), then `coverage xml -o coverage-project.xml`. Rotating local projects are not part of this public-repo gate; dedicated project jobs own their own toolchains.
- **Codecov upload:** On Python 3.12 only
#### 6. fep_lean — real Open Gauss + Lake (`fep-lean`)
- **Conditional:** Job is **skipped** unless `projects/fep_lean/lean/lean-toolchain` exists and the `detect` job emits `fep_lean == 'true'`. When fep_lean lives under `projects/working/`, `detect` reports `false` and the job is skipped. Promote with `mv projects/working/fep_lean projects/fep_lean` to activate.
- **Runner:** `ubuntu-latest` / Python 3.12 only; job `timeout-minutes: 60`
- **Depends on:** `verify-no-mocks`
- **Working directory (when present):** `projects/fep_lean` for pytest; `projects/fep_lean/lean` for Lake warm-up
- **Toolchain:** SHA-pinned elan installer (with checksum verification) + pinned
`lean-toolchain`, `lake build` warm-up
- **Open Gauss:** clone [math-inc/OpenGauss](https://github.com/math-inc/OpenGauss), `./scripts/install.sh --plain --noninteractive --skip-system-packages`, `gauss doctor`
- **Tests:** `uv run pytest tests/ --timeout=1200 --cov=src --cov-fail-under=89` with `COVERAGE_FILE: ../../.coverage.fep_lean`
- **Scaling:** Full catalogue × Lean is expensive; if runtime grows past the job budget, split slow integration tests behind a pytest marker or shard topics in a follow-up workflow.
#### 7. Validate Manuscripts (`validate`)
- **Runner:** `ubuntu-latest` / Python 3.12
- **Steps:**
1. `infrastructure.validation.cli markdown projects/*/manuscript/` — validates all active project manuscripts
2. `scripts/docgen/api_reference.py --check` — API reference drift gate
3. Dynamic project import check — imports the public project source paths from `infrastructure.project.public_scope`
#### 8. Security Scan (`security`)
- **Runner:** `ubuntu-latest` / Python 3.12
- **pip-audit:** blocking; builds `--ignore-vuln` args from [`.github/pip-audit-ignore.txt`](../pip-audit-ignore.txt); retries up to **3** times with backoff on failure (transient OSV/network issues)
- **bandit:** `bandit -c bandit.yaml -r -ll`, covers `infrastructure/`, `scripts/`, `projects/`; path exclusions are in `bandit.yaml` (`exclude_dirs`, including archive/WIP roots and `.venv` / `site-packages` so local trees are not scanned)
#### 9. Documentation Lint (`docs-lint`)
- **Runner:** `ubuntu-latest` / Python 3.12 / Node 20-compatible actions
- **Depends on:** `lint`
- **Timeout:** 15 minutes
- **External tools (real, not mocked):**
- `mmdc` (mermaid-cli) — pinned in the root `package.json`; run `npm ci`
- `chrome-headless-shell` — `npx --no-install puppeteer browsers install chrome-headless-shell`, exported via `CHROME_EXECUTABLE_PATH`
- **Linters (thin orchestrator [`scripts/audit/lint_docs.py`](../../scripts/audit/lint_docs.py)):**
1. **Mermaid** — every fenced \`\`\`mermaid block in `docs/`, `infrastructure/`, `.github/`, `scripts/`, and root `*.md` is rendered with the real `mmdc` binary. Failure exits non-zero.
2. **Cross-links** — every relative Markdown link must resolve on disk; fenced and inline-code spans are skipped.
3. **Consistency** — `N Python (sub)packages` claims must match the live count under `infrastructure/`; rotating project names (`fep_lean`, `cogant`, …) must be conditionally framed in long-lived docs.
4. **Doc pairs** — permanent-template content folders must carry paired `AGENTS.md` and `README.md`; generated/local paths and rotating projects are excluded.
- **Escape hatch:** append `` to a Markdown line to suppress consistency or broken-link warnings on that line.
- **Scope guarantees:** the linter skips generated/local paths such as `output/`, `.venv/`, `.claude/`, `projects/archive/`, `projects/working/`, `htmlcov/`, and `node_modules/`.
- **Module:** [`infrastructure/validation/docs/`](../../infrastructure/validation/docs/) — `mermaid_lint.py`, `cross_link_lint.py`, `consistency_lint.py`, `doc_pair_lint.py`.
#### 10. Performance Check (`performance`)
- **Runner:** `ubuntu-latest` / Python 3.12
- **Depends on:** `test-infra` + `test-project`
- **Threshold:** each `infrastructure.core` or public project `src` cold import from `infrastructure.project.public_scope` must complete in ≤ 5 seconds
- **Per-module timing** and the roster-dependent total are reported to stdout for trend analysis
### Quality Gates
| Gate | Threshold | Enforced by |
|---|---|---|
| Ruff linting | zero violations | `lint` job |
| Ruff formatting | zero diffs | `lint` job |
| mypy strict gate | zero errors across the generated public source scope | `lint` job |
| Mock-framework lexical gate | zero prohibited imports/calls | `verify-no-mocks` job |
| Public capability parity | exact roster/matrix; compatible Python floors; valid package/render/hydration/analysis declarations | `detect-projects` + unified `health` |
| Infrastructure coverage | ≥ 60% | `test-infra` job |
| Per-project coverage (standalone) | ≥ 90% | each project's own pytest gate |
| Combined-union public-project coverage | ≥ 75% | `test-project` job (`DEFAULT_FAIL_UNDER`) |
| fep_lean coverage | ≥ 89% | `fep-lean` job (skipped if `projects/fep_lean/lean/lean-toolchain` absent) |
| pip-audit | no unignored vulnerabilities | `security` job |
| Bandit MEDIUM+ (`bandit.yaml`) | zero findings | `security` job |
| Import time | ≤ 5 seconds total | `performance` job |
---
## Stale Workflow (`stale.yml`)
Runs daily at 01:00 UTC using `actions/stale@v10.3.0`.
| Item | Stale after | Closed after |
|---|---|---|
| Issues | 60 days inactive | + 14 days |
| Pull Requests | 30 days inactive | + 14 days |
**Exempt labels:** `pinned`, `security`, `in-progress`, `blocked`, `do-not-close`
---
## Release Workflow (`release.yml`)
Triggers on `v*.*.*` tag push or `workflow_dispatch` (with tag input).
1. Resolves the requested tag before checkout and checks out that exact ref
2. Proves `HEAD` equals the dereferenced tag commit and runs the root release contract
3. Runs the bounded pipeline-smoke infrastructure test lane + no-mocks gate on the tagged SHA (executable test evidence, without the per-commit full matrix)
4. Runs the fail-closed public capability manifest
5. Clean-exports, installs, and import-smokes every canonical public exemplar without credentials
6. Runs the strict rendered publication audit across every canonical public exemplar before building
7. Generates a commit-based changelog excerpt since the previous tag
8. Creates a GitHub Release using `softprops/action-gh-release@v3.0.2` with **`generate_release_notes: false`** so the body is the git-log excerpt only (no duplicate auto-generated section)
9. Auto-marks as pre-release if tag contains `-rc`, `-beta`, or `-alpha`
Current pinned GitHub Actions use the Node 20 action runtime. GitHub-hosted runners satisfy this; self-hosted runners must be Actions runner `v2.327.1` or newer.
## Dependabot Automerge Workflow (`dependabot-automerge.yml`)
Triggers on `pull_request_target` (opened, reopened, synchronize, ready_for_review) — but only acts when `github.actor == 'dependabot[bot]'`.
1. Fetches Dependabot update metadata via `dependabot/fetch-metadata@v3.1.0`
2. Enables GitHub native auto-merge (`gh pr merge --auto --squash`) for `semver-minor` and `semver-patch` updates only; major bumps are left for human review
3. Never checks out or executes PR HEAD code — only reads metadata and calls the GitHub API — so the elevated `pull_request_target` trigger does not expose secrets to untrusted code
4. Auto-merge must also be enabled in repository settings (Settings → General → "Allow auto-merge")
---
## Local CI Simulation
```bash
# Reproduce lint locally
uv sync
uv run python -m infrastructure.project.public_scope lint-paths | xargs uv run ruff check
uv run python -m infrastructure.project.public_scope lint-paths | xargs uv run ruff format --check
uv run python -m infrastructure.project.public_scope source-paths | xargs uv run mypy
# Reproduce infrastructure tests locally
COVERAGE_FILE=.coverage.infra uv run pytest tests/infra_tests/ \
--cov=infrastructure \
--cov-fail-under=60 \
-m "not requires_ollama"
# Reproduce project tests locally (matrix job ignores fep_lean). The root
# default groups include the deterministic public-exemplar dependency union.
uv sync
COVERAGE_FILE=.coverage.project uv run python scripts/pipeline/stage_01_test.py --project-only --all-projects --public-projects --non-strict --include-slow
uv run coverage xml -o coverage-project.xml
# fep_lean only — requires gauss, lake, lean on PATH (see that project's tests/AGENTS.md when present)
(cd projects/fep_lean && COVERAGE_FILE=../../.coverage.fep_lean uv run pytest tests/ \
--timeout=900 \
--cov=src \
--cov-fail-under=89 \
-m "not requires_ollama")
# Reproduce security scan locally (mirror CI — build ignores from file)
IGNORE_ARGS=()
while IFS= read -r raw || [ -n "$raw" ]; do
[[ "$raw" =~ ^[[:space:]]*# ]] && continue
line="${raw%%#*}"
line="$(echo "$line" | xargs)"
[ -z "$line" ] && continue
IGNORE_ARGS+=(--ignore-vuln "$line")
done < .github/pip-audit-ignore.txt
uv run pip-audit "${IGNORE_ARGS[@]}"
uv run bandit -c bandit.yaml -r -ll infrastructure/ scripts/ projects/
```
---
## Troubleshooting
### Linting failures
```bash
uv run python -m infrastructure.project.public_scope lint-paths | xargs uv run ruff check --fix
uv run python -m infrastructure.project.public_scope lint-paths | xargs uv run ruff format
```
### Test failures
```bash
# Infrastructure
uv run pytest tests/infra_tests/ -v --tb=long -s
# Project tests — prefer the orchestrator (runs one pytest per project; avoids conftest/package collisions):
uv run python scripts/pipeline/stage_01_test.py --project template_code_project
# Advanced / blanket globs — running **all** `projects/*/tests/` in **one** pytest process can fail when multiple projects ship `tests/conftest` packages with identical names; use per-project directories instead.
uv run pytest projects/*/tests/ -v --tb=long -s
# Single test
uv run pytest tests/infra_tests/test_foo.py::TestClass::test_method -s --pdb
```
### Coverage below threshold
```bash
# Infrastructure report
COVERAGE_FILE=.coverage.infra uv run pytest tests/infra_tests/ \
--cov=infrastructure --cov-report=html
open htmlcov/index.html
# Project report — replace SRC_PATH with `projects//src` from _generated/active_projects.md when benchmarking coverage manually:
COVERAGE_FILE=.coverage.project uv run pytest projects/templates/template_code_project/tests/ \
--cov=projects/templates/template_code_project/src --cov-report=html
open htmlcov/index.html
```
### Performance check slow
```bash
# Profile imports
uv run python -c "
import cProfile, pstats, io
pr = cProfile.Profile()
pr.enable()
import infrastructure.core
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats('cumulative')
ps.print_stats(20)
print(s.getvalue())
"
```
---
## See Also
- [`../README.md`](../README.md) — `.github/` human guide (workflows, Dependabot, templates)
- [`../AGENTS.md`](../AGENTS.md) — GitHub integration overview
- [`../../AGENTS.md`](../../AGENTS.md) — Root system overview and pipeline stages
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
- [uv Documentation](https://docs.astral.sh/uv/)
- [Codecov Documentation](https://docs.codecov.com/)