python-ci
Purpose
Build the GitHub Actions quality gate for a Python repository: workflows that run
lint, type check, and tests across a Python version matrix with uv caching, funnel
into one all-checks-passed aggregator job, get enforced as required status checks
behind branch rulesets, and are hardened against the ways CI gets silently weakened
(skipped jobs that count as success, renamed matrix legs that strand PRs, mutable
action tags, over-broad GITHUB_TOKEN scopes, pull_request_target and cache
trust-boundary holes). CI is the one enforcement layer that cannot be bypassed with
--no-verify — treat the workflow files themselves as production code.
When NOT to use
- Release/publish workflows (tag-triggered PyPI publish, trusted publishing,
version bumps) — the python-release skill, if installed, owns those. This skill
only ensures the quality gate a release job can
needs: on.
- pre-commit config or its CI mirror job — the python-precommit skill.
- coverage.py configuration (
fail_under values, relative_files, source
paths) — the python-testing skill. This skill wires coverage through the
workflow (per-leg artifacts, combine job placement) only.
- Dependabot, secret scanning, CodeQL, Scorecard, SBOMs, CODEOWNERS — the
python-supply-chain skill.
- Fixing the failures CI reports (lint errors, type errors, failing tests) —
the python-lint / python-typing skills or ordinary debugging.
Workflow
1. Survey the repo before writing YAML
- Read
pyproject.toml: which tools are configured ([tool.ruff], mypy/pyright/ty,
pytest), the requires-python range (it defines the matrix), and dependency
groups (dev or dependency-groups).
- Check for a committed
uv.lock. If present, CI must use uv sync --locked —
never regenerate the lockfile in the runner; lockfile changes belong in commits.
- List existing
.github/workflows/ files; extend or replace deliberately, and
check repo settings for merge queues (they change the trigger set — step 5).
- CI runs what the repo already defines. Do not introduce new tools or looser
variants: the gate must run the same commands developers run locally, in
check mode (
ruff check, ruff format --check) — if an earlier layer
auto-fixes, CI only verifies. A make check that runs --fix must not be
reused as a CI step.
- First CI run on an existing codebase: run the lint/type/test commands locally
first. Enabling gates on a never-linted repo fails hard; land a cleanup commit
(or hand that off to the relevant skill) before flipping the gate on.
2. Author the quality-gate workflow
Create .github/workflows/ci.yml. Full annotated template with the coverage and
aggregator jobs wired in: references/ci-workflow-template.md.
The load-bearing choices:
name: CI
on:
push:
branches: [main]
pull_request:
merge_group: # without this, merge-queue validation silently never reports
permissions:
contents: read # job-level blocks widen this only where needed
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
- Jobs:
lint (ruff check + format --check), typecheck (whichever checker the
repo configures), test (pytest across the matrix). Two valid orderings — pick
one and say why: all three in parallel (fastest wall-clock feedback) or
test: needs: lint (a 10-second lint failure skips the expensive matrix; slower
feedback when lint passes). Parallel is the safer default for small suites.
- Environment:
astral-sh/setup-uv with enable-cache: true and a
python-version: ${{ matrix.python-version }} input — no separate
actions/setup-python needed. Install with uv sync --locked --group dev, run
tools with uv run <tool>. (No uv? actions/setup-python@v7 with cache: pip
plus pip install -e . --group dev (pip >= 25.1 reads PEP 735 groups) and direct tool commands is the fallback; state it
once and move on.)
- Matrix: every version in
requires-python, quoted — ['3.11', '3.12', '3.13', '3.14'] (3.10 reaches EOL 2026-10; 3.15 ships 2026-10-01). Unquoted 3.10 is YAML for the float 3.1. Decision rule for
fail-fast: false when you want full cross-version signal (default here),
true only for quick PR loops where first-failure is enough. OS axis only if the
package does platform-dependent work; each axis multiplies billed minutes
(hard cap 256 jobs per workflow run).
- Pin every action to a full commit SHA (step 6) from the first draft, not as a
later pass.
3. Wire coverage through the matrix (workflow side only)
If the repo measures coverage, per-leg gating is wrong: a leg that skips
version-specific code fails even when combined coverage is fine.
- Each matrix leg runs
pytest --cov --cov-fail-under=0 with
COVERAGE_FILE=.coverage.py${{ matrix.python-version }} (pytest-cov writes one
.coverage per leg and applies the config's fail_under per leg unless told
not to), then uploads it as an artifact: unique name including the matrix
values, and include-hidden-files: true because .coverage.* files are hidden.
- A
coverage job (needs: test) downloads all legs, runs uv run coverage combine and uv run coverage report. The threshold value and
relative_files = true (required for cross-runner combining) live in the repo's
coverage config — python-testing territory; this skill only places the gate in
the combine job.
- Uploading to an external service (Codecov etc.)? Upload from the combine job or
one canonical leg — every-leg uploads produce duplicate/conflicting reports.
4. Add the aggregator job — the only required check
Matrix legs and job names change; branch settings do not follow them. Gate on one
stable job:
all-checks-passed:
runs-on: ubuntu-latest
needs: [lint, typecheck, test, coverage]
if: always() # without this the job is SKIPPED when a dep fails — and skipped counts as success
steps:
- name: Fail if any needed job did not succeed
run: |
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}" == "true" ]]; then
echo "::error::A required job failed, was cancelled, or was skipped."
exit 1
fi
echo "All checks passed."
skipped is in the test on purpose: a misconfigured if: on a dependency
then surfaces instead of silently passing. Only if some jobs are
intentionally path-filtered do you drop it — and document why (the reference
has the path-filter variant).
- Keep the job name stable; renaming it means updating branch settings in the
same PR or nothing merges.
5. Make it required — rulesets or branch protection
Run the workflow once first (push a branch / open a test PR): checks only appear in
the required-checks picker after they have reported at least once.
Prefer rulesets (layerable, target tags too, org-level, "Evaluate" preview
mode, fine-grained bypass actors); classic branch protection is fine for a single
simple repo. Configure via API to avoid drift — classic:
# --field/-F send strings (and coerce bools/ints), never nested objects — give the
# API a JSON body instead.
gh api repos/{owner}/{repo}/branches/main/protection --method PUT --input - <<'JSON'
{ "required_status_checks": { "strict": true, "contexts": ["all-checks-passed"] },
"enforce_admins": true,
"required_pull_request_reviews": { "required_approving_review_count": 1, "dismiss_stale_reviews": true },
"restrictions": null }
JSON
- Mark only
all-checks-passed as required. Individual matrix legs as
required checks stall every PR the moment the matrix changes ("waiting for
status to be reported").
- Enable "do not allow bypassing" / keep
enforce_admins on — otherwise an admin
(or an agent holding an admin token) merges past every check.
strict: true (branch must be up to date) on a busy main creates a rebase
treadmill; a merge queue is the structural fix. With a merge queue, the
workflow must trigger on merge_group, and required checks are validated
at queue time.
- Tag protection (blocking re-tagging of releases) is a ruleset feature; add a
tag ruleset when the repo publishes artifacts.
Ruleset JSON, merge-queue specifics, and a diagnostic playbook for "PR is green
but won't merge": references/required-checks-and-rulesets.md.
6. Harden the workflows
Details, rationale, and incident history: references/workflow-hardening.md.
SHA-pin every action — mutable tags get repointed (the 2025 tj-actions
compromise shipped malicious code to every @v consumer). Resolve tags:
git ls-remote https://github.com/astral-sh/setup-uv refs/tags/v10\*
# the `^{}` line is the commit SHA for annotated tags — pin that one
Format: uses: owner/action@<40-hex-sha> # v10.0.1 — keep the version comment;
note astral-sh/setup-uv deleted its floating major/minor tags at 8.0.0, so
@v10 does not resolve at all — only full versions or SHAs do;
update tooling and humans both key off it.
Minimal GITHUB_TOKEN: workflow-level permissions: contents: read,
job-level additions only where needed. Never rely on the repo-wide default.
Static-analyze the workflows with zizmor (template injection, dangerous
triggers, excessive permissions), pinned:
uvx zizmor==1.30.1 .github/workflows/
Check https://github.com/zizmorcore/zizmor for the current release and bump the
pin deliberately. actionlint (PyPI: actionlint-py, pin the release you
verify) additionally catches structural/syntax errors a formatter won't.
Trust boundaries: avoid pull_request_target unless you fully understand
it — combined with checkout of PR code or cache read/write it is a live secret
exfiltration and cache-poisoning vector. Actions caches are shared per-repo
namespace: never cache secrets, and treat fork-PR-writable caches as untrusted
input to later trusted jobs.
Run scripts/check_workflows.py (below) to catch regressions in all of the
above deterministically.
7. Verify end to end
python3 "${CLAUDE_SKILL_DIR}/scripts/check_workflows.py" --repo . # unpinned actions, float versions, missing if: always(), ...
uvx zizmor==1.30.1 .github/workflows/ # security findings
git switch -c ci-setup && git push -u origin ci-setup
gh pr create --fill && gh pr checks --watch # every job green, aggregator reports
Then confirm all-checks-passed is selectable and selected in the branch
rules, and — if a merge queue is on — that a queued PR actually merges.
Output spec — what done looks like
.github/workflows/ci.yml: lint + typecheck + test-matrix + (coverage) +
all-checks-passed, triggers include pull_request and merge_group,
workflow-level permissions: contents: read, concurrency cancellation for PRs,
every action SHA-pinned with a version comment, quoted matrix versions,
uv sync --locked.
- Branch ruleset (or classic protection) on
main requiring only
all-checks-passed, admins included, stale-review dismissal on.
scripts/check_workflows.py and zizmor both exit 0 on the final tree.
- A PR that ran the full gate green — and, stated in the PR description, what the
gate enforces and that job renames must update branch settings in the same PR.
Failure modes & gotchas
| Symptom / risk |
Cause & fix |
| PR "waiting for status to be reported" forever |
Required check bound to a renamed/removed job or matrix leg. Gate on the aggregator; update settings and job name in the same PR. |
| Aggregator green while jobs failed |
Missing if: always() — the job was skipped, and skipped required checks count as success. The explicit result check is load-bearing. |
| Merge queue stuck though PR checks passed |
Workflow lacks the merge_group trigger, so the required check never reports at queue time. |
| Matrix runs Python 3.1 |
Unquoted 3.10 parsed as float. Quote every version. |
| One failure cancels all matrix legs |
fail-fast defaults to true; set false when debugging or when full cross-version signal matters. |
| Coverage fails on one leg, fine overall |
Per-leg threshold enforcement. Combine first, gate once, in the combine job. |
| Required check can't be selected in settings |
It has never run. Trigger the workflow once (push/PR), then select it. |
| Merges blocked though everything passed |
Duplicate job names across workflow files make the check context ambiguous — keep job names unique repo-wide. |
| CI passes after an agent's PR, gate is weaker |
Review workflow diffs for || true, deleted steps, loosened triggers, edited thresholds. Server-side required checks + admin enforcement are the only layer a local agent cannot self-modify around; CODEOWNERS on .github/ (supply-chain skill) adds review. |
| Workflow didn't trigger after a bot push/tag |
Events created with the default GITHUB_TOKEN do not start new workflow runs — chaining needs a GitHub App token or PAT (python-release territory). |
| Format check fails right after a Ruff release |
Tool versions must come from the lockfile (uv sync --locked), so CI and local runs use identical versions — never pip install ruff unpinned in a workflow step. |
| Cache poisoning via fork PRs |
pull_request_target + cache write is an escalation path. Since 2026-06-26 untrusted triggers (pull_request_target, issue_comment, fork workflow_run) get a read-only cache token for default-branch scopes — treat that as the floor and still design as if fork-writable caches are hostile. |
Bundled resources
- references/ci-workflow-template.md —
complete annotated
ci.yml (matrix, coverage combine, aggregator) plus
variants: needs:-chained jobs, path-filtered aggregator, dynamic matrix.
- references/required-checks-and-rulesets.md —
aggregator pattern options, rulesets vs classic protection,
gh api recipes,
merge queues, tag rulesets, blocked-merge diagnostic playbook.
- references/workflow-hardening.md — SHA
pinning (and the Dependabot-alerts tradeoff),
GITHUB_TOKEN scoping, zizmor
and actionlint usage, pull_request_target and cache trust boundaries.
scripts/check_workflows.py — read-only hygiene scanner for
.github/workflows/; non-zero exit on findings, one machine-readable line per
finding. Run it in step 7 and after any workflow edit.
1---2name: python-ci3description: Authors GitHub Actions quality-gate workflows for a Python repo — lint/type/test jobs, version matrices, uv caching, an all-checks-passed aggregator, required checks, rulesets, merge queues, SHA pinning, zizmor hardening. Use for 'set up continuous integration quality gates', 'make checks required', 'move branch protection to rulesets', 'harden the workflows'. Not for release pipelines or commit hooks.4license: MIT5---67# python-ci89## Purpose1011Build the GitHub Actions quality gate for a Python repository: workflows that run12lint, type check, and tests across a Python version matrix with uv caching, funnel13into one `all-checks-passed` aggregator job, get enforced as required status checks14behind branch rulesets, and are hardened against the ways CI gets silently weakened15(skipped jobs that count as success, renamed matrix legs that strand PRs, mutable16action tags, over-broad `GITHUB_TOKEN` scopes, `pull_request_target` and cache17trust-boundary holes). CI is the one enforcement layer that cannot be bypassed with18`--no-verify` — treat the workflow files themselves as production code.1920## When NOT to use2122- **Release/publish workflows** (tag-triggered PyPI publish, trusted publishing,23 version bumps) — the python-release skill, if installed, owns those. This skill24 only ensures the quality gate a release job can `needs:` on.25- **pre-commit config or its CI mirror job** — the python-precommit skill.26- **coverage.py configuration** (`fail_under` values, `relative_files`, source27 paths) — the python-testing skill. This skill wires coverage *through the28 workflow* (per-leg artifacts, combine job placement) only.29- **Dependabot, secret scanning, CodeQL, Scorecard, SBOMs, CODEOWNERS** — the30 python-supply-chain skill.31- **Fixing the failures CI reports** (lint errors, type errors, failing tests) —32 the python-lint / python-typing skills or ordinary debugging.3334## Workflow3536### 1. Survey the repo before writing YAML3738- Read `pyproject.toml`: which tools are configured (`[tool.ruff]`, mypy/pyright/ty,39 pytest), the `requires-python` range (it defines the matrix), and dependency40 groups (`dev` or `dependency-groups`).41- Check for a committed `uv.lock`. If present, CI must use `uv sync --locked` —42 never regenerate the lockfile in the runner; lockfile changes belong in commits.43- List existing `.github/workflows/` files; extend or replace deliberately, and44 check repo settings for merge queues (they change the trigger set — step 5).45- CI runs what the repo already defines. Do not introduce new tools or looser46 variants: the gate must run the same commands developers run locally, in47 **check mode** (`ruff check`, `ruff format --check`) — if an earlier layer48 auto-fixes, CI only verifies. A `make check` that runs `--fix` must not be49 reused as a CI step.50- First CI run on an existing codebase: run the lint/type/test commands locally51 first. Enabling gates on a never-linted repo fails hard; land a cleanup commit52 (or hand that off to the relevant skill) before flipping the gate on.5354### 2. Author the quality-gate workflow5556Create `.github/workflows/ci.yml`. Full annotated template with the coverage and57aggregator jobs wired in: [references/ci-workflow-template.md](references/ci-workflow-template.md).58The load-bearing choices:5960```yaml61name: CI62on:63 push:64 branches: [main]65 pull_request:66 merge_group: # without this, merge-queue validation silently never reports6768permissions:69 contents: read # job-level blocks widen this only where needed7071concurrency:72 group: ${{ github.workflow }}-${{ github.ref }}73 cancel-in-progress: ${{ github.event_name == 'pull_request' }}74```7576- **Jobs**: `lint` (ruff check + format --check), `typecheck` (whichever checker the77 repo configures), `test` (pytest across the matrix). Two valid orderings — pick78 one and say why: all three in parallel (fastest wall-clock feedback) or79 `test: needs: lint` (a 10-second lint failure skips the expensive matrix; slower80 feedback when lint passes). Parallel is the safer default for small suites.81- **Environment**: `astral-sh/setup-uv` with `enable-cache: true` and a82 `python-version: ${{ matrix.python-version }}` input — no separate83 `actions/setup-python` needed. Install with `uv sync --locked --group dev`, run84 tools with `uv run <tool>`. (No uv? `actions/setup-python@v7` with `cache: pip`85 plus `pip install -e . --group dev` (pip >= 25.1 reads PEP 735 groups) and direct tool commands is the fallback; state it86 once and move on.)87- **Matrix**: every version in `requires-python`, quoted — `['3.11', '3.12',88 '3.13', '3.14']` (3.10 reaches EOL 2026-10; 3.15 ships 2026-10-01). Unquoted `3.10` is YAML for the float `3.1`. Decision rule for89 `fail-fast`: `false` when you want full cross-version signal (default here),90 `true` only for quick PR loops where first-failure is enough. OS axis only if the91 package does platform-dependent work; each axis multiplies billed minutes92 (hard cap 256 jobs per workflow run).93- Pin every action to a full commit SHA (step 6) from the first draft, not as a94 later pass.9596### 3. Wire coverage through the matrix (workflow side only)9798If the repo measures coverage, per-leg gating is wrong: a leg that skips99version-specific code fails even when combined coverage is fine.100101- Each matrix leg runs `pytest --cov --cov-fail-under=0` with102 `COVERAGE_FILE=.coverage.py${{ matrix.python-version }}` (pytest-cov writes one103 `.coverage` per leg and applies the config's `fail_under` per leg unless told104 not to), then uploads it as an artifact: unique name including the matrix105 values, and `include-hidden-files: true` because `.coverage.*` files are hidden.106- A `coverage` job (`needs: test`) downloads all legs, runs `uv run coverage107 combine` and `uv run coverage report`. The threshold *value* and108 `relative_files = true` (required for cross-runner combining) live in the repo's109 coverage config — python-testing territory; this skill only places the gate in110 the combine job.111- Uploading to an external service (Codecov etc.)? Upload from the combine job or112 one canonical leg — every-leg uploads produce duplicate/conflicting reports.113114### 4. Add the aggregator job — the only required check115116Matrix legs and job names change; branch settings do not follow them. Gate on one117stable job:118119```yaml120 all-checks-passed:121 runs-on: ubuntu-latest122 needs: [lint, typecheck, test, coverage]123 if: always() # without this the job is SKIPPED when a dep fails — and skipped counts as success124 steps:125 - name: Fail if any needed job did not succeed126 run: |127 if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}" == "true" ]]; then128 echo "::error::A required job failed, was cancelled, or was skipped."129 exit 1130 fi131 echo "All checks passed."132```133134- `skipped` is in the test on purpose: a misconfigured `if:` on a dependency135 then surfaces instead of silently passing. Only if some jobs are136 intentionally path-filtered do you drop it — and document why (the reference137 has the path-filter variant).138- Keep the job name stable; renaming it means updating branch settings in the139 same PR or nothing merges.140141### 5. Make it required — rulesets or branch protection142143Run the workflow once first (push a branch / open a test PR): checks only appear in144the required-checks picker after they have reported at least once.145146Prefer **rulesets** (layerable, target tags too, org-level, "Evaluate" preview147mode, fine-grained bypass actors); classic branch protection is fine for a single148simple repo. Configure via API to avoid drift — classic:149150```bash151# --field/-F send strings (and coerce bools/ints), never nested objects — give the152# API a JSON body instead.153gh api repos/{owner}/{repo}/branches/main/protection --method PUT --input - <<'JSON'154{ "required_status_checks": { "strict": true, "contexts": ["all-checks-passed"] },155 "enforce_admins": true,156 "required_pull_request_reviews": { "required_approving_review_count": 1, "dismiss_stale_reviews": true },157 "restrictions": null }158JSON159```160161- Mark **only** `all-checks-passed` as required. Individual matrix legs as162 required checks stall every PR the moment the matrix changes ("waiting for163 status to be reported").164- Enable "do not allow bypassing" / keep `enforce_admins` on — otherwise an admin165 (or an agent holding an admin token) merges past every check.166- `strict: true` (branch must be up to date) on a busy main creates a rebase167 treadmill; a merge queue is the structural fix. With a merge queue, the168 workflow **must** trigger on `merge_group`, and required checks are validated169 at queue time.170- Tag protection (blocking re-tagging of releases) is a ruleset feature; add a171 tag ruleset when the repo publishes artifacts.172173Ruleset JSON, merge-queue specifics, and a diagnostic playbook for "PR is green174but won't merge": [references/required-checks-and-rulesets.md](references/required-checks-and-rulesets.md).175176### 6. Harden the workflows177178Details, rationale, and incident history: [references/workflow-hardening.md](references/workflow-hardening.md).179180- **SHA-pin every action** — mutable tags get repointed (the 2025 tj-actions181 compromise shipped malicious code to every `@v` consumer). Resolve tags:182183 ```bash184 git ls-remote https://github.com/astral-sh/setup-uv refs/tags/v10\*185 # the `^{}` line is the commit SHA for annotated tags — pin that one186 ```187188 Format: `uses: owner/action@<40-hex-sha> # v10.0.1` — keep the version comment;189 note `astral-sh/setup-uv` deleted its floating major/minor tags at 8.0.0, so190 `@v10` does not resolve at all — only full versions or SHAs do;191 update tooling and humans both key off it.192- **Minimal `GITHUB_TOKEN`**: workflow-level `permissions: contents: read`,193 job-level additions only where needed. Never rely on the repo-wide default.194- **Static-analyze the workflows** with zizmor (template injection, dangerous195 triggers, excessive permissions), pinned:196197 ```bash198 uvx zizmor==1.30.1 .github/workflows/199 ```200201 Check https://github.com/zizmorcore/zizmor for the current release and bump the202 pin deliberately. actionlint (PyPI: `actionlint-py`, pin the release you203 verify) additionally catches structural/syntax errors a formatter won't.204- **Trust boundaries**: avoid `pull_request_target` unless you fully understand205 it — combined with checkout of PR code or cache read/write it is a live secret206 exfiltration and cache-poisoning vector. Actions caches are shared per-repo207 namespace: never cache secrets, and treat fork-PR-writable caches as untrusted208 input to later trusted jobs.209- Run `scripts/check_workflows.py` (below) to catch regressions in all of the210 above deterministically.211212### 7. Verify end to end213214```bash215python3 "${CLAUDE_SKILL_DIR}/scripts/check_workflows.py" --repo . # unpinned actions, float versions, missing if: always(), ...216uvx zizmor==1.30.1 .github/workflows/ # security findings217git switch -c ci-setup && git push -u origin ci-setup218gh pr create --fill && gh pr checks --watch # every job green, aggregator reports219```220221Then confirm `all-checks-passed` is selectable and selected in the branch222rules, and — if a merge queue is on — that a queued PR actually merges.223224## Output spec — what done looks like225226- `.github/workflows/ci.yml`: lint + typecheck + test-matrix + (coverage) +227 `all-checks-passed`, triggers include `pull_request` and `merge_group`,228 workflow-level `permissions: contents: read`, concurrency cancellation for PRs,229 every action SHA-pinned with a version comment, quoted matrix versions,230 `uv sync --locked`.231- Branch ruleset (or classic protection) on `main` requiring only232 `all-checks-passed`, admins included, stale-review dismissal on.233- `scripts/check_workflows.py` and zizmor both exit 0 on the final tree.234- A PR that ran the full gate green — and, stated in the PR description, what the235 gate enforces and that job renames must update branch settings in the same PR.236237## Failure modes & gotchas238239| Symptom / risk | Cause & fix |240| --- | --- |241| PR "waiting for status to be reported" forever | Required check bound to a renamed/removed job or matrix leg. Gate on the aggregator; update settings and job name in the same PR. |242| Aggregator green while jobs failed | Missing `if: always()` — the job was skipped, and **skipped required checks count as success**. The explicit result check is load-bearing. |243| Merge queue stuck though PR checks passed | Workflow lacks the `merge_group` trigger, so the required check never reports at queue time. |244| Matrix runs Python 3.1 | Unquoted `3.10` parsed as float. Quote every version. |245| One failure cancels all matrix legs | `fail-fast` defaults to `true`; set `false` when debugging or when full cross-version signal matters. |246| Coverage fails on one leg, fine overall | Per-leg threshold enforcement. Combine first, gate once, in the combine job. |247| Required check can't be selected in settings | It has never run. Trigger the workflow once (push/PR), then select it. |248| Merges blocked though everything passed | Duplicate job names across workflow files make the check context ambiguous — keep job names unique repo-wide. |249| CI passes after an agent's PR, gate is weaker | Review workflow diffs for `\|\| true`, deleted steps, loosened triggers, edited thresholds. Server-side required checks + admin enforcement are the only layer a local agent cannot self-modify around; CODEOWNERS on `.github/` (supply-chain skill) adds review. |250| Workflow didn't trigger after a bot push/tag | Events created with the default `GITHUB_TOKEN` do not start new workflow runs — chaining needs a GitHub App token or PAT (python-release territory). |251| Format check fails right after a Ruff release | Tool versions must come from the lockfile (`uv sync --locked`), so CI and local runs use identical versions — never `pip install ruff` unpinned in a workflow step. |252| Cache poisoning via fork PRs | `pull_request_target` + cache write is an escalation path. Since 2026-06-26 untrusted triggers (`pull_request_target`, `issue_comment`, fork `workflow_run`) get a read-only cache token for default-branch scopes — treat that as the floor and still design as if fork-writable caches are hostile. |253254## Bundled resources255256- [references/ci-workflow-template.md](references/ci-workflow-template.md) —257 complete annotated `ci.yml` (matrix, coverage combine, aggregator) plus258 variants: `needs:`-chained jobs, path-filtered aggregator, dynamic matrix.259- [references/required-checks-and-rulesets.md](references/required-checks-and-rulesets.md) —260 aggregator pattern options, rulesets vs classic protection, `gh api` recipes,261 merge queues, tag rulesets, blocked-merge diagnostic playbook.262- [references/workflow-hardening.md](references/workflow-hardening.md) — SHA263 pinning (and the Dependabot-alerts tradeoff), `GITHUB_TOKEN` scoping, zizmor264 and actionlint usage, `pull_request_target` and cache trust boundaries.265- `scripts/check_workflows.py` — read-only hygiene scanner for266 `.github/workflows/`; non-zero exit on findings, one machine-readable line per267 finding. Run it in step 7 and after any workflow edit.