mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* 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.
199 lines
9.2 KiB
Python
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"
|