Install uv without an action so the matrix jobs stop racing
act caches each action as one shared git clone under /root/.cache/act/<hash> and re-pulls it per job. The three python matrix jobs start within the same second on the self-hosted runner, race on that directory, and the loser dies with "lstat /root/.cache/act/<hash>/.npmrc: no such file or directory" before any test runs — a red main with zero suite output and a different victim each push (3.12, then 3.11). The action was only fetching a binary; the matrix interpreter is selected per command by uvx --python. A run: step has no action-cache entry and cannot race, and keeps the jobs parallel — serialising the matrix would cost 3x the wall clock and still leave actions/checkout shared across four jobs. The uv version is pinned under the same rule as ruff. The accompanying test parses uses: directives rather than the raw text, so the comment can still name the action it avoids, and uses re instead of PyYAML because CI runs uvx pytest, whose environment holds pytest and nothing else.
This commit is contained in:
@@ -47,7 +47,29 @@ jobs:
|
|||||||
# download path setup-python relies on does not work on the self-hosted Gitea
|
# 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);
|
# runner (all three matrix jobs failed at setup there while passing on GitHub);
|
||||||
# uv works identically on both.
|
# 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/<hash> 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/<hash>/.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
|
- name: ruff
|
||||||
# Pinned: an unpinned ruff means any upstream release can turn main red
|
# Pinned: an unpinned ruff means any upstream release can turn main red
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ worse than no alert, because one day it carries a security fix.
|
|||||||
|
|
||||||
### Fixed
|
### 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/<hash>` 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/<hash>/.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
|
- **The patch survived being applied and then silently stopped existing, because
|
||||||
something else remounted `/usr` four seconds later.** On a box running
|
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
|
TrueNAS 25.10.6 the boot of 2026-08-19 went: 16:41:56 `apply.sh` mounts its
|
||||||
|
|||||||
@@ -172,3 +172,43 @@ class TestCompatCannotSilentlyPass:
|
|||||||
with open(os.path.join(WORKFLOWS, "compat.yml"), encoding="utf-8") as fh:
|
with open(os.path.join(WORKFLOWS, "compat.yml"), encoding="utf-8") as fh:
|
||||||
src = fh.read()
|
src = fh.read()
|
||||||
assert "steps.check.outputs.shipped_broken != '0'" in src
|
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/<hash>` and the loser dies with `lstat
|
||||||
|
.../<file>: 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
|
||||||
|
|||||||
Reference in New Issue
Block a user