Both found by the subscriptions session in its copy of this kit, and both reproduce here. They share the shape everything else in ci/ has had: the failure is invisible on GitHub, which is where anyone would be looking. 1. ci/workflows-in-sync.sh never parsed the workflow files on Gitea, and printed "Workflows: in sync." anyway. It skipped with a note when PyYAML was missing — and PyYAML is missing in exactly one place. Measured here rather than taken on report: `import yaml` on gitea/runner-images:ubuntu-latest raises ModuleNotFoundError. So the check was switched off on the single forge where an unparseable workflow produces no run at all, no failed run and no error to click on, which looks exactly like a runner that has not picked the job up. ci_python_yaml in ci/lib.sh now falls back to a container the way ci_php already did — alpine + py3-yaml, no network install, one apk call. There is no skip path left: workflows that cannot be parsed are a failure, not a note. Both routes verified to give byte-identical output. 2. compat.yml ran its five-cell matrix on Gitea for every PR touching ci/ or tests/. The `schedule` was gated to GitHub for exactly this reason and `pull_request` was forgotten. The subscriptions repo measured six jobs dying together with exit code 137 — SIGKILL, runner out of memory, two seconds in, without one error message in the log. Latent here only because this repo merges through GitHub and never opens a Gitea PR; one Gitea PR touching ci/ would have found it. New ci/check-workflows.py enforces both, called through ci_python_yaml. It parses every workflow and fails when a Gitea job with more than one matrix cell is not gated, and it is fatal when it parsed no file at all. 🔴 Its first version asked whether the `if:` CONTAINED the server_url test, and passed the very condition it exists to catch: `!= 'schedule' || server_url == ...` contains the gate while letting pull_request through. Substring matching is adjacent to the signal rather than the signal. It now compares the whole normalised condition against an allowlist, and was checked both ways in one run — red against the unfixed compat.yml naming the job and the cell count, green after. Three self-test assertions added, 29 total: an unparseable workflow fails, an ungated multi-cell matrix fails, and parsing zero files fails. Two notes from building it, both the same trap this kit keeps setting. The container path could not find check-workflows.py because ci_in_container tars `git ls-files` and the file was untracked. And the ungated-matrix fixture used | as a sed delimiter while the gate itself contains ||, so sed silently changed nothing; the assertion reported "could not stage the fixture" instead of passing, which is the only reason it was noticed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Why this directory exists, and the trap it carries
Gitea uses .gitea/workflows if it exists, and falls back to
.github/workflows only when it does not. It never merges them.
So adding one Gitea-only workflow here silently switches off CI — and therefore auto-merge — for the whole repository on Gitea. There is no error and no run to click on. It looks exactly like a runner that has not picked the job up, so you wait, and waiting never fixes it.
Every workflow that must run on Gitea has to be in this directory.
ci.yml and compat.yml are kept byte-identical in both roots, and
ci/workflows-in-sync.sh fails if the two copies drift apart or if a workflow
appears in only one of them without a stated reason.
release.yml is the deliberate exception: a release workflow publishes to
whichever forge runs it, and the two copies publish to different places. The
Gitea one is the one that matters — that is where the update feed shops read
lives.
Two further rules learned the hard way:
- No
concurrency:block in a Gitea workflow. It produces no run at all — not a failed one, not a cancelled one. Same shape of trap, same symptom. - If a Gitea workflow produces no run, suspect the file, not the runner.