Skip to content

Commit 919901e

Browse files
authored
fix(release): stop cutting a release when only the dev tools moved (#354)
uv.lock carries the dev group, both images build with `uv sync --frozen --no-dev`, and the release trigger matched on the filename. v1.36.4 shipped for a pytest, pytest-asyncio and ruff bump: 78 entries in site-packages, not one different from v1.36.3, and three containers restarted on the fleet for a version label. scripts/runtime_deps_changed.py exports the no-dev resolution at both refs with the images' own uv and compares it, hashes and markers included. The manifests gate that check instead of triggering a build by name. Replayed over the two releases that prompted it: v1.36.3..v1.36.4 builds nothing, v1.36.2..v1.36.3 builds both, an app/ change still builds the UI. Fail-safe throughout: uv missing, a ref that will not resolve, an export that fails, a lock that disagrees with its pyproject, no tag to compare against — every one answers "changed" and says why. A pointless image costs a build; a release that silently does not happen ships nothing while the run reports success. The fetch-tags guard decided a job runs the suite by searching its whole run block for "pytest", so a comment about pytest read as a command. Comments are stripped now, with a control that a real suite job losing fetch-tags still goes red.
1 parent 45f81a8 commit 919901e

4 files changed

Lines changed: 406 additions & 4 deletions

File tree

.github/workflows/release.yml

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,28 @@ jobs:
6464
with:
6565
fetch-depth: 0
6666

67+
# The change detector asks uv what the no-dev resolution was at the last
68+
# tag. Without uv on PATH the script says "changed" and is fail-safe but
69+
# useless, so install it before anything reads its answer.
70+
#
71+
# The SAME uv the images build with, read out of the Dockerfile rather
72+
# than restated here: uv guarantees lockfile compatibility within a minor
73+
# release, and a detector on a different one would be judging a
74+
# resolution nobody ships. Warns rather than fails if it cannot read the
75+
# version -- an unpinned uv still compares both refs with one binary, so
76+
# the answer stays sound; tests/test_release_triggers.py is what keeps the
77+
# Dockerfile readable. (CodeRabbit, PR #354.)
78+
- name: Install uv
79+
run: |
80+
UV_VERSION=$(grep -oE 'astral-sh/uv:[0-9]+\.[0-9]+\.[0-9]+' Dockerfile | head -1 | cut -d: -f2)
81+
if [ -z "$UV_VERSION" ]; then
82+
echo "::warning::could not read the uv version from Dockerfile; installing the newest"
83+
pip install uv --quiet
84+
else
85+
echo "Installing uv $UV_VERSION, the version Dockerfile builds with"
86+
pip install "uv==$UV_VERSION" --quiet
87+
fi
88+
6789
- name: Detect what changed
6890
id: changes
6991
run: |
@@ -94,9 +116,33 @@ jobs:
94116
# lan_isolation, notify, ...) produced a GREEN release run that
95117
# published nothing at all: no tag, no GitHub Release, no images.
96118
# Skipped steps do not fail a run, so it looked like a success.
97-
if echo "$CHANGED" | grep -qE '^(Dockerfile|entrypoint\.sh|pyproject\.toml|uv\.lock|app/)'; then
119+
# uv.lock and pyproject.toml hold the DEV group too, and both images
120+
# build with `uv sync --frozen --no-dev`. Matching on the filename
121+
# meant a pytest or ruff bump -- the most frequent dependency PR there
122+
# is -- cut a full release. v1.36.4 was exactly that: 78 entries in
123+
# site-packages, not one of them different from v1.36.3, and three
124+
# containers restarted on the fleet for a version label.
125+
#
126+
# So the manifests do not trigger by name. The script exports the
127+
# no-dev resolution at both refs with the real resolver and compares,
128+
# and every way of not knowing answers "changed".
129+
RUNTIME_CHANGED=false
130+
if echo "$CHANGED" | grep -qE '^(pyproject\.toml|uv\.lock)$'; then
131+
if [ -n "$LAST_TAG" ]; then
132+
RUNTIME_CHANGED=$(python3 scripts/runtime_deps_changed.py "$LAST_TAG" HEAD || echo true)
133+
else
134+
RUNTIME_CHANGED=true
135+
fi
136+
fi
137+
echo "Runtime dependencies changed: $RUNTIME_CHANGED"
138+
139+
if echo "$CHANGED" | grep -qE '^(Dockerfile|entrypoint\.sh|app/)'; then
98140
BUILD_UI=true
99141
fi
142+
if [ "$RUNTIME_CHANGED" = true ]; then
143+
BUILD_UI=true
144+
BUILD_WORKER=true
145+
fi
100146
if echo "$CHANGED" | grep -qE '^services/'; then
101147
BUILD_UI=true
102148
BUILD_WORKER=true
@@ -111,7 +157,7 @@ jobs:
111157
WORKER_MODULES=$(grep -oE '^COPY[^#]*app/([a-z_]+)\.py' Dockerfile.worker \
112158
| grep -oE 'app/[a-z_]+\.py' | sort -u)
113159
echo "Worker modules (from Dockerfile.worker):"; echo "$WORKER_MODULES"
114-
if echo "$CHANGED" | grep -qE '^(Dockerfile\.worker|entrypoint\.sh|pyproject\.toml|uv\.lock)'; then
160+
if echo "$CHANGED" | grep -qE '^(Dockerfile\.worker|entrypoint\.sh)'; then
115161
BUILD_WORKER=true
116162
fi
117163
while IFS= read -r mod; do

scripts/runtime_deps_changed.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env python3
2+
"""Did the dependencies that SHIP change between two refs?
3+
4+
``uv.lock`` holds the dev group as well as the runtime one, and release.yml
5+
treated any change to it as a reason to build. So a bump of pytest or ruff --
6+
the most frequent kind of dependency PR there is -- cut a full release whose
7+
images were identical to the previous one. v1.36.4 was exactly that: 78 entries
8+
in site-packages, zero difference from v1.36.3, three containers restarted on
9+
the fleet for a version label.
10+
11+
Both images build with ``uv sync --frozen --no-dev``, so the question that
12+
decides a release is not "did uv.lock change" but "did the no-dev resolution
13+
change". This answers that by exporting it at both refs with the real resolver
14+
and comparing.
15+
16+
python scripts/runtime_deps_changed.py v1.36.3 HEAD # -> false
17+
python scripts/runtime_deps_changed.py v1.36.2 v1.36.3 # -> true
18+
19+
FAIL-SAFE, and this is the whole design. Every way of not knowing -- uv is
20+
missing, a ref does not exist, an export fails, the lock disagrees with
21+
pyproject -- prints ``true`` and explains itself on stderr. A release that
22+
should not have happened costs a pointless image. A release that silently did
23+
not happen ships nothing while the run reports success, which is the failure
24+
this repo has been bitten by before.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import argparse
30+
import pathlib
31+
import subprocess
32+
import sys
33+
import tempfile
34+
35+
#: What both Dockerfiles copy before `uv sync`. Nothing else feeds the resolution.
36+
MANIFESTS = ("pyproject.toml", "uv.lock")
37+
38+
39+
def _warn(message: str) -> None:
40+
print(f"runtime_deps_changed: {message}", file=sys.stderr)
41+
42+
43+
def _run(args: list[str], **kwargs) -> subprocess.CompletedProcess[str]:
44+
"""Never raise. A missing binary is an answer, not a crash.
45+
46+
``subprocess.run`` raises FileNotFoundError when the executable is absent,
47+
which is precisely the case this script has to survive: no uv on PATH must
48+
mean "assume it changed", not a traceback that fails the release job.
49+
"""
50+
try:
51+
return subprocess.run(args, capture_output=True, text=True, timeout=180, check=False, **kwargs)
52+
except (OSError, subprocess.SubprocessError) as exc:
53+
return subprocess.CompletedProcess(args, returncode=127, stdout="", stderr=str(exc))
54+
55+
56+
def _materialise(repo: pathlib.Path, ref: str, into: pathlib.Path) -> bool:
57+
"""Write the manifests as they were at ``ref``. False if any is unreadable."""
58+
for name in MANIFESTS:
59+
result = _run(["git", "-C", str(repo), "show", f"{ref}:{name}"])
60+
if result.returncode != 0:
61+
_warn(f"cannot read {name} at {ref}: {result.stderr.strip()}")
62+
return False
63+
(into / name).write_text(result.stdout, encoding="utf-8")
64+
return True
65+
66+
67+
def _runtime_requirements(directory: pathlib.Path) -> set[str] | None:
68+
"""Everything the no-dev resolution pins, or None when uv cannot say.
69+
70+
``--frozen`` so uv reports a lock that disagrees with its pyproject instead
71+
of quietly re-resolving it, which would need the network and would answer a
72+
different question from the one the Dockerfile asks.
73+
74+
HASHES ARE INCLUDED. Exporting with ``--no-hashes`` and keeping only the
75+
``name==version`` lines compares less than the build consumes: a lock can
76+
gain or change an artifact for a version that already exists -- a new wheel
77+
for a platform, a re-resolved sdist -- and every pin still reads the same
78+
while ``uv sync --frozen`` installs something different. That returns
79+
"unchanged" for a change that ships, which is the one direction this script
80+
must never get wrong. (CodeRabbit, PR #354.)
81+
82+
Comment lines go, and only those. uv writes the command it was run with into
83+
the header, and that names the temp directory, so it differs on every call
84+
by construction. The ``# via ...`` provenance notes are dropped with it;
85+
they restate the graph the pins already describe.
86+
"""
87+
result = _run(
88+
[
89+
"uv",
90+
"export",
91+
"--directory",
92+
str(directory),
93+
"--frozen",
94+
"--no-dev",
95+
"--format",
96+
"requirements-txt",
97+
]
98+
)
99+
if result.returncode != 0:
100+
_warn(f"uv export failed in {directory}: {result.stderr.strip()[:400]}")
101+
return None
102+
return {line.strip() for line in result.stdout.splitlines() if line.strip() and not line.strip().startswith("#")}
103+
104+
105+
def runtime_deps_changed(repo: pathlib.Path, base: str, head: str) -> bool:
106+
with tempfile.TemporaryDirectory() as tmp:
107+
root = pathlib.Path(tmp)
108+
exported = []
109+
for ref in (base, head):
110+
into = root / ref.replace("/", "_")
111+
into.mkdir(parents=True, exist_ok=True)
112+
if not _materialise(repo, ref, into):
113+
_warn("assuming the runtime dependencies changed")
114+
return True
115+
requirements = _runtime_requirements(into)
116+
if requirements is None:
117+
_warn("assuming the runtime dependencies changed")
118+
return True
119+
exported.append(requirements)
120+
121+
before, after = exported
122+
if before == after:
123+
_warn(f"{len(before)} runtime requirements, identical between {base} and {head}")
124+
return False
125+
126+
for pin in sorted(after - before):
127+
_warn(f" + {pin}")
128+
for pin in sorted(before - after):
129+
_warn(f" - {pin}")
130+
return True
131+
132+
133+
def main(argv: list[str] | None = None) -> int:
134+
parser = argparse.ArgumentParser(description=__doc__)
135+
parser.add_argument("base", help="the ref to compare from, usually the last release tag")
136+
parser.add_argument("head", nargs="?", default="HEAD")
137+
parser.add_argument("--repo", default=".", help="repository root (default: cwd)")
138+
args = parser.parse_args(argv)
139+
140+
if _run(["uv", "--version"]).returncode != 0:
141+
_warn("uv is not on PATH; assuming the runtime dependencies changed")
142+
print("true")
143+
return 0
144+
145+
print("true" if runtime_deps_changed(pathlib.Path(args.repo), args.base, args.head) else "false")
146+
return 0
147+
148+
149+
if __name__ == "__main__":
150+
sys.exit(main())

tests/test_compose_image_pins.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,17 @@ def _runs_the_drift_test(command: str) -> bool:
147147
requiring tags there would be a rule about a problem that workflow
148148
cannot have. The first version of this guard flagged it, which is how
149149
the distinction got noticed.
150+
151+
Comments are stripped first. release.yml's change detector explains that
152+
a pytest bump used to cut a release, and on the raw text that prose read
153+
as a job that runs the suite — the guard matching someone's writing
154+
rather than a command, which is the same way the skip-marker guard in
155+
test_beads_batch_65.py once fooled itself.
150156
"""
151-
if "pytest" not in command:
157+
commands = "\n".join(line for line in command.splitlines() if not line.strip().startswith("#"))
158+
if "pytest" not in commands:
152159
return False
153-
return " -m " not in command and " -k " not in command
160+
return " -m " not in commands and " -k " not in commands
154161

155162
def _workflows_running_pytest(self):
156163
import yaml
@@ -174,6 +181,11 @@ def test_a_marker_filtered_run_is_not_required_to_fetch_tags(self):
174181
assert self._runs_the_drift_test("uv run pytest")
175182
assert self._runs_the_drift_test("pytest tests/ -v --tb=short")
176183

184+
def test_prose_about_pytest_is_not_a_pytest_run(self):
185+
"""The control for the comment-stripping, which is the whole point."""
186+
assert not self._runs_the_drift_test("# a pytest bump used to cut a release\npip install uv --quiet")
187+
assert self._runs_the_drift_test("# install first\nuv run pytest tests/")
188+
177189
def test_each_such_job_checks_out_with_tags(self):
178190
offenders = []
179191
for name, doc in self._workflows_running_pytest():

0 commit comments

Comments
 (0)