deer-flow/backend/tests/test_verify_versions_lockfile.py
Hyeonsang Cho db9b0b4bf0
fix(release): reject a stale backend/uv.lock in the version gate (#6588)
* fix(release): reject a stale backend/uv.lock in the version gate

verify_versions.sh compared only Chart.yaml, pyproject.toml and
package.json, so bumping those three by hand passed the v* gate while
backend/uv.lock still recorded the previous root package version. The
backend image installs with uv sync --locked, so the chart and the other
images published while the backend image failed to build.

Run uv lock --check in backend/ as part of the gate, letting uv own the
PEP 440 normalization, and fail when uv is missing. The reusable
verify-versions workflow installs the uv version the backend image pins.

* docs(changelog): reference #6588

* fix(release): explain an unparsed lock version and stop blaming the lock for every uv failure

Print a fallback when the lock's root entry is not found, so the job log
does not show an empty field, and word the uv lock --check annotation so a
network error or uv crash is not reported as a stale lock.
2026-10-10 14:05:35 +08:00

199 lines
9.2 KiB
Python

"""Regression test: the release gate must reject a stale ``backend/uv.lock``.
``scripts/verify_versions.sh`` gates every ``v*`` publish (``chart.yaml`` and
``container.yaml`` through ``verify-versions.yml``), but it compared only
``Chart.yaml``, ``pyproject.toml`` and ``package.json``. ``backend/uv.lock``
records the root package version too, and ``backend/Dockerfile`` installs with
``uv sync --locked``. Bumping the three text sources by hand therefore passed
the gate::
$ scripts/verify_versions.sh 2.2.0
OK — all version sources agree on 2.2.0.
$ cd backend && uv sync --locked --extra redis --extra postgres
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
On a tag, the chart and the frontend and provisioner images published while the
backend image build failed, and the immutable chart version meant the fix
needed a new version number. ``uv lock --check`` in lint CI does not run on
tags.
The gate now asks uv itself (``uv lock --check``), which owns the PEP 440
normalization (``2.1.0-rc0`` is stored as ``2.1.0rc0``), and refuses to pass
without uv.
"""
from __future__ import annotations
import os
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
import yaml
from support.shell import find_script_bash
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT_BASH = find_script_bash()
VERIFY_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "verify-versions.yml"
pytestmark = pytest.mark.skipif(
SCRIPT_BASH is None or os.name == "nt",
reason="the fake `uv` on PATH is an extensionless shebang script; Windows does not execute those",
)
# Fake `uv lock --check`: the lock is current when its root entry records the
# pyproject version in uv's PEP 440 form (for these inputs, without the `-`).
# The invocation and its working directory are logged so a test can tell
# whether the gate asked uv at all, and from backend/. FAKE_UV_ERROR makes it
# fail for a reason other than a stale lock (a network error, a crash).
FAKE_UV = """#!/usr/bin/env python3
import os
import re
import sys
from pathlib import Path
log = Path(__file__).with_name("uv-calls.log")
with log.open("a", encoding="utf-8") as handle:
handle.write(os.getcwd() + " uv " + " ".join(sys.argv[1:]) + "\\n")
if sys.argv[1:] != ["lock", "--check"]:
sys.exit(f"fake uv: unsupported invocation {sys.argv[1:]}")
if os.environ.get("FAKE_UV_ERROR"):
sys.exit(os.environ["FAKE_UV_ERROR"])
root = Path.cwd()
declared = re.search(r'(?m)^version\\s*=\\s*"([^"]+)"', (root / "pyproject.toml").read_text(encoding="utf-8")).group(1)
recorded = re.search(r'(?m)^name = "deer-flow"\\nversion = "([^"]+)"', (root / "uv.lock").read_text(encoding="utf-8"))
if recorded is None or recorded.group(1) != declared.replace("-", ""):
sys.exit("The lockfile at `uv.lock` needs to be updated, but `--check` was provided. To update the lockfile, run `uv lock`.")
print("Resolved 1 package")
"""
def _write_sandbox(root: Path, *, sources: str, lock: str, lock_name: str = "deer-flow") -> Path:
"""A repo-shaped tree with every version source at ``sources`` and the lock at ``lock``."""
(root / "scripts").mkdir(parents=True)
(root / "backend").mkdir()
(root / "frontend").mkdir()
(root / "deploy" / "helm" / "deer-flow").mkdir(parents=True)
shutil.copy2(REPO_ROOT / "scripts" / "verify_versions.sh", root / "scripts" / "verify_versions.sh")
(root / "backend" / "pyproject.toml").write_text(f'[project]\nname = "deer-flow"\nversion = "{sources}"\n', encoding="utf-8")
(root / "backend" / "uv.lock").write_text(
f'version = 1\n\n[[package]]\nname = "{lock_name}"\nversion = "{lock}"\nsource = {{ virtual = "." }}\n',
encoding="utf-8",
)
(root / "frontend" / "package.json").write_text(f'{{\n "name": "deer-flow-frontend",\n "version": "{sources}"\n}}\n', encoding="utf-8")
(root / "deploy" / "helm" / "deer-flow" / "Chart.yaml").write_text(
f'apiVersion: v2\nname: deer-flow\nversion: {sources}\nappVersion: "{sources}"\n',
encoding="utf-8",
)
return root
def _install_fake_uv(root: Path) -> Path:
bin_dir = root / "fake-bin"
bin_dir.mkdir()
uv = bin_dir / "uv"
uv.write_text(FAKE_UV, encoding="utf-8")
uv.chmod(uv.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
(bin_dir / "uv-calls.log").write_text("", encoding="utf-8")
return bin_dir
def _run_verify(root: Path, *args: str, path: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[SCRIPT_BASH, str(root / "scripts" / "verify_versions.sh"), *args],
cwd=root,
env={**os.environ, **(env or {}), "PATH": path},
capture_output=True,
text=True,
check=False,
)
def _with_fake_uv(bin_dir: Path) -> str:
return f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"
def _uv_calls(bin_dir: Path) -> list[str]:
return (bin_dir / "uv-calls.log").read_text(encoding="utf-8").splitlines()
@pytest.mark.parametrize(("sources", "lock"), [("2.2.0", "2.2.0"), ("2.1.0-rc1", "2.1.0rc1")], ids=["release", "prerelease"])
def test_gate_passes_when_the_lock_matches(tmp_path: Path, sources: str, lock: str):
root = _write_sandbox(tmp_path / "repo", sources=sources, lock=lock)
bin_dir = _install_fake_uv(root)
result = _run_verify(root, sources, path=_with_fake_uv(bin_dir))
assert result.returncode == 0, result.stderr
assert f"backend/uv.lock: {lock}" in result.stdout
assert _uv_calls(bin_dir) == [f"{(root / 'backend').resolve()} uv lock --check"]
@pytest.mark.parametrize("args", [("2.2.0",), ()], ids=["against-tag", "mutual"])
def test_gate_rejects_a_lock_left_on_the_previous_version(tmp_path: Path, args: tuple[str, ...]):
"""The three text sources were bumped by hand; the lock was not."""
root = _write_sandbox(tmp_path / "repo", sources="2.2.0", lock="2.2.0.dev0")
bin_dir = _install_fake_uv(root)
result = _run_verify(root, *args, path=_with_fake_uv(bin_dir))
assert result.returncode == 1
assert "::error::'uv lock --check' failed on backend/uv.lock (output above); if the lock is stale, run scripts/bump_version.sh" in result.stderr
assert "needs to be updated" in result.stderr, "uv's own explanation should reach the job log"
assert "Tip: run scripts/bump_version.sh" in result.stderr
assert "OK —" not in result.stdout
def test_gate_does_not_blame_the_lock_for_an_unrelated_uv_failure(tmp_path: Path):
"""A uv failure that is not a stale lock is still fatal, but the annotation must not claim staleness."""
root = _write_sandbox(tmp_path / "repo", sources="2.2.0", lock="2.2.0")
bin_dir = _install_fake_uv(root)
result = _run_verify(root, "2.2.0", path=_with_fake_uv(bin_dir), env={"FAKE_UV_ERROR": "error: Failed to fetch: `https://pypi.org/simple/langgraph/`"})
assert result.returncode == 1
assert "Failed to fetch" in result.stderr, "uv's own explanation should reach the job log"
assert "::error::'uv lock --check' failed on backend/uv.lock (output above); if the lock is stale, run scripts/bump_version.sh" in result.stderr
assert "out of date" not in result.stderr
def test_gate_explains_a_lock_without_the_root_entry(tmp_path: Path):
"""The printed lock version is for the job log; an unparsable lock must say so instead of printing nothing."""
root = _write_sandbox(tmp_path / "repo", sources="2.2.0", lock="2.2.0", lock_name="deer-flow-renamed")
bin_dir = _install_fake_uv(root)
result = _run_verify(root, "2.2.0", path=_with_fake_uv(bin_dir))
assert result.returncode == 1
assert "backend/uv.lock: (root entry not found; 'uv lock --check' is authoritative)" in result.stdout
assert "::error::'uv lock --check' failed on backend/uv.lock (output above); if the lock is stale, run scripts/bump_version.sh" in result.stderr
def test_gate_refuses_to_pass_without_uv(tmp_path: Path):
"""Skipping the lock check when uv is absent would reopen the gap silently."""
root = _write_sandbox(tmp_path / "repo", sources="2.2.0", lock="2.2.0")
# Keep every PATH entry that does not provide a `uv`, so the script still
# finds awk/grep and can only fail on the missing uv itself.
without_uv = os.pathsep.join(entry for entry in os.environ.get("PATH", "").split(os.pathsep) if entry and not (Path(entry) / "uv").exists())
assert shutil.which("uv", path=without_uv) is None, "test setup: uv is still reachable on PATH"
assert shutil.which("awk", path=without_uv) is not None, "test setup: awk must stay reachable"
result = _run_verify(root, "2.2.0", path=without_uv)
assert result.returncode == 1
assert "::error::uv is required to check backend/uv.lock" in result.stderr
def test_release_gate_workflow_installs_uv_before_verifying():
steps = yaml.safe_load(VERIFY_WORKFLOW.read_text(encoding="utf-8"))["jobs"]["verify-versions"]["steps"]
setup_uv = [index for index, step in enumerate(steps) if str(step.get("uses", "")).startswith("astral-sh/setup-uv@")]
verify = [index for index, step in enumerate(steps) if "scripts/verify_versions.sh" in str(step.get("run", ""))]
assert setup_uv and verify, f"verify-versions.yml must install uv and run verify_versions.sh: {steps}"
assert setup_uv[0] < verify[0], "uv must be installed before the gate runs its lock check"