diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfaaf02..099c4b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,29 @@ jobs: # download path setup-python relies on does not work on the self-hosted Gitea # runner (all three matrix jobs failed at setup there while passing on GitHub); # uv works identically on both. - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + # + # Installed by a plain `run:` step rather than astral-sh/setup-uv, because + # act -- the engine behind the Gitea runner -- caches each ACTION as a single + # shared git clone under /root/.cache/act/ and re-pulls it per job. The + # three matrix jobs start within the same second on one runner, so they race + # on that directory and whichever loses dies with + # + # lstat /root/.cache/act//.npmrc: no such file or directory + # + # before a single test runs -- a red `main` with zero suite output, and a + # different victim each time (3.12 on one push, 3.11 on the next). A `run:` + # step has no action-cache entry, so it cannot race. Serialising the matrix + # would have been the other option; it costs 3x the wall clock and still + # leaves actions/checkout sharing a cache across the four jobs. + # + # Pinned for the same reason ruff is pinned below: an unpinned uv means any + # upstream release can turn main red with no code change here. + - name: install uv + env: + UV_VERSION: "0.11.21" + run: | + curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: ruff # Pinned: an unpinned ruff means any upstream release can turn main red diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d777c..cb7cb5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,24 @@ worse than no alert, because one day it carries a security fix. ### Fixed +- **CI turned `main` red on two of three pushes without running a single test.** + `act`, the engine behind the self-hosted Gitea runner, caches each *action* as + one shared git clone under `/root/.cache/act/` and re-pulls it per job. + The three matrix jobs start within the same second on one runner, so they race + on that directory and whichever loses dies with `lstat + /root/.cache/act//.npmrc: no such file or directory` — before any suite + output exists, with a different victim each push (3.12 on one, 3.11 on the + next). A red gate that is usually noise is worse than no gate, because the one + time it means something nobody looks. + + `uv` is now installed by a plain `run:` step instead of `astral-sh/setup-uv`. + A `run:` step has no action-cache entry and cannot race, and the action was + only ever fetching a binary — the matrix interpreter is chosen per command by + `uvx --python`, not by the action. Serialising the matrix was the alternative; + it costs 3x the wall clock and still leaves `actions/checkout` shared across + the four jobs. The uv version is pinned under the same rule as ruff: an + unpinned tool lets an upstream release turn `main` red with no change here. + - **The patch survived being applied and then silently stopped existing, because something else remounted `/usr` four seconds later.** On a box running TrueNAS 25.10.6 the boot of 2026-08-19 went: 16:41:56 `apply.sh` mounts its diff --git a/tests/test_workflows.py b/tests/test_workflows.py index e617213..38f9c59 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -172,3 +172,43 @@ class TestCompatCannotSilentlyPass: with open(os.path.join(WORKFLOWS, "compat.yml"), encoding="utf-8") as fh: src = fh.read() assert "steps.check.outputs.shipped_broken != '0'" in src + + +class TestActionCacheRace: + """act caches each ACTION as one shared clone and re-pulls it per job. + + Matrix jobs start within the same second on the self-hosted Gitea runner, so + they race on `/root/.cache/act/` and the loser dies with `lstat + .../: no such file or directory` before any test runs -- a red `main` + with zero suite output and a different victim each push. Fewer actions in a + fan-out job means fewer directories to race on. + """ + + def test_uv_is_installed_without_an_action(self): + """Checks `uses:` directives, not prose. + + The comment in ci.yml names the action it deliberately avoids, and that + explanation is the most useful thing in the file -- a test that greps the + raw text would forbid documenting the very lesson it enforces. Parsed + with a regex rather than PyYAML on purpose: CI runs `uvx pytest`, whose + environment holds pytest and nothing else, so a third-party import here + fails on the runner while passing locally. + """ + with open(os.path.join(WORKFLOWS, "ci.yml"), encoding="utf-8") as fh: + ci = fh.read() + used = re.findall(r"^\s*-?\s*uses:\s*(\S+)", ci, re.M) + assert not [u for u in used if "setup-uv" in u], ( + "installing uv via an action reintroduces the act action-cache race " + "that turned main red on two of three pushes; install it in a run: step" + ) + assert "astral.sh/uv/" in ci + + def test_the_uv_version_is_pinned(self): + with open(os.path.join(WORKFLOWS, "ci.yml"), encoding="utf-8") as fh: + ci = fh.read() + assert re.search(r'UV_VERSION:\s*"\d+\.\d+\.\d+"', ci), ( + "an unpinned uv lets any upstream release turn main red with no " + "code change here -- the same rule ruff is pinned under" + ) + # The version must be used, not just declared. + assert 'https://astral.sh/uv/${UV_VERSION}/install.sh' in ci