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.
This commit is contained in:
Hyeonsang Cho 2026-10-10 15:05:35 +09:00 committed by GitHub
parent 6c4c74a504
commit db9b0b4bf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 280 additions and 20 deletions

View File

@ -76,8 +76,9 @@ jobs:
verify-versions:
# Gate the release: every version source must match the v* tag. A forgotten
# bump in Chart.yaml, pyproject.toml, or package.json fails here and skips
# the publish. See scripts/verify_versions.sh.
# bump in Chart.yaml, pyproject.toml, or package.json, or a stale
# backend/uv.lock, fails here and skips the publish. See
# scripts/verify_versions.sh.
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/verify-versions.yml

View File

@ -8,8 +8,9 @@ on:
jobs:
verify-versions:
# Gate the release: every version source must match the v* tag. A forgotten
# bump in Chart.yaml, pyproject.toml, or package.json fails here and skips
# all image builds. See scripts/verify_versions.sh.
# bump in Chart.yaml, pyproject.toml, or package.json, or a stale
# backend/uv.lock, fails here and skips all image builds. See
# scripts/verify_versions.sh.
uses: ./.github/workflows/verify-versions.yml
backend-container:

View File

@ -6,8 +6,9 @@ name: Verify Versions
# images + chart), not just the chart.
#
# Sources verified: deploy/helm/deer-flow/Chart.yaml (version + appVersion),
# backend/pyproject.toml, frontend/package.json. Logic lives in
# scripts/verify_versions.sh so it can also be run locally.
# backend/pyproject.toml, frontend/package.json, and backend/uv.lock (via
# `uv lock --check`, which the backend image's `uv sync --locked` depends on).
# Logic lives in scripts/verify_versions.sh so it can also be run locally.
on:
workflow_call:
@ -21,6 +22,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
# Must match backend/Dockerfile's UV_IMAGE tag; pinned by backend/tests/test_ci_uv_version_pin.py
version: "0.11.1"
- name: Verify all version sources match tag
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"

View File

@ -4126,6 +4126,17 @@ This release closes that milestone with **439 merged pull requests**.
value that yields no valid ID denies every guild and logs an error. Unset,
`null`, `[]`, or a blank string still allows all guilds; `allowed_channels`
gains the same scalar handling. ([#6338])
- **release:** The `v*` release gate now rejects a stale `backend/uv.lock`.
`scripts/verify_versions.sh` compared only `Chart.yaml`, `pyproject.toml` and
`package.json`, so bumping those three by hand passed the gate even though the
lock still recorded the previous root package version. The backend image
installs with `uv sync --locked`, so on a tag the chart and the frontend and
provisioner images published while the backend image failed to build, and the
immutable chart version meant the fix needed a new version number. The script
now also runs `uv lock --check` in `backend/` (uv owns the PEP 440
normalization, so `2.1.0-rc0` still matches `2.1.0rc0`) and fails when `uv` is
missing; `verify-versions.yml` installs the uv version the backend image pins.
([#6588])
### Security
@ -9452,3 +9463,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6558]: https://github.com/bytedance/deer-flow/pull/6558
[#6580]: https://github.com/bytedance/deer-flow/pull/6580
[#6582]: https://github.com/bytedance/deer-flow/pull/6582
[#6588]: https://github.com/bytedance/deer-flow/pull/6588

View File

@ -3170,6 +3170,14 @@
条目在警告中丢弃,而配置了却得不到任何有效 ID 的值会拒绝所有公会并记录
错误;未设置、`null`、`[]` 或空白字符串仍允许所有公会。
`allowed_channels` 获得同样的标量处理。([#6338])
- **发布:** `v*` 发布门禁现在会拦下过期的 `backend/uv.lock`。此前
`scripts/verify_versions.sh` 只比较 `Chart.yaml`、`pyproject.toml` 和
`package.json`,手动改这三处就能通过门禁,而 lock 里记录的根包版本仍是旧版本。
backend 镜像用 `uv sync --locked` 安装依赖,因此打标签后 chart 以及 frontend、
provisioner 镜像都已发布,backend 镜像却构建失败;chart 版本不可覆盖,修复只能
换一个新版本号。现在该脚本还会在 `backend/` 中运行 `uv lock --check`(PEP 440
规范化交给 uv,`2.1.0-rc0` 仍与 `2.1.0rc0` 匹配),缺少 `uv` 时直接失败;
`verify-versions.yml` 会安装与 backend 镜像相同的固定 uv 版本。([#6588])
### 安全
@ -7613,3 +7621,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6556]: https://github.com/bytedance/deer-flow/pull/6556
[#6580]: https://github.com/bytedance/deer-flow/pull/6580
[#6582]: https://github.com/bytedance/deer-flow/pull/6582
[#6588]: https://github.com/bytedance/deer-flow/pull/6588

View File

@ -42,7 +42,8 @@ distinguishes it from a release.
```
- `scripts/verify_versions.sh [version]` — check that all sources agree. With
no argument it requires mutual equality; with an argument it requires every
source to equal it. Exits non-zero on mismatch. Run it locally before tagging
source to equal it. It also runs `uv lock --check` in `backend/`, so it needs
`uv` on `PATH` too. Exits non-zero on mismatch. Run it locally before tagging
to catch drift early:
```bash
scripts/verify_versions.sh 2.1.0
@ -149,12 +150,15 @@ Both features stay opt-in: the provisioner ignores them until
Both publishing workflows call `.github/workflows/verify-versions.yml` as their
first job. It runs `scripts/verify_versions.sh` against the tag (minus the
`v`). If any of the four version sources doesn't match the tag, the verify job
fails and **all** publish jobs are skipped — no images, no chart.
`v`). If any of the four version sources doesn't match the tag, or
`backend/uv.lock` is stale, the verify job fails and **all** publish jobs are
skipped — no images, no chart.
The gate covers those four fields. `backend/uv.lock` is refreshed to the same
version by `scripts/bump_version.sh` and checked by `uv lock --check` in lint CI,
which fails on a stale lock.
The lock records the root package version in uv's PEP 440 form, so the gate
checks it with `uv lock --check` (the job installs the same pinned uv as the
backend image) rather than comparing strings. Without that check, a lock left on
the previous version would pass the gate and then fail the backend image's
`uv sync --locked`, after the chart and the other images had already published.
When it fails, the job annotation names the offending file and suggests the
fix:

View File

@ -55,8 +55,9 @@ UV_LOCK = 'version = 1\n\n[[package]]\nname = "deer-flow"\nversion = "2.1.0rc0"\
# Emulates the uv behavior the release flow depends on: `uv lock` records the
# pyproject version in the root lock entry, in uv's PEP 440 form (for these
# inputs that only means dropping the `-`). The invocation is logged so the test
# can tell whether the script called uv at all.
# inputs that only means dropping the `-`), and `uv lock --check` (run by the
# verify_versions.sh self-check) fails while the entry lags. The invocation is
# logged so the test can tell whether the script called uv at all.
FAKE_UV = """#!/usr/bin/env python3
import re
import sys
@ -67,7 +68,7 @@ with log.open("a", encoding="utf-8") as handle:
handle.write("uv " + " ".join(sys.argv[1:]) + "\\n")
args = sys.argv[1:]
if args != ["lock"]:
if args not in (["lock"], ["lock", "--check"]):
sys.exit(f"fake uv: unsupported invocation {args}")
root = Path.cwd()
@ -78,6 +79,8 @@ declared = re.search(r'(?m)^version\\s*=\\s*"([^"]+)"', pyproject).group(1)
normalized = declared.replace("-", "")
recorded = re.search(r'(?m)^name = "deer-flow"\\nversion = "([^"]+)"', lock_text).group(1)
if args == ["lock", "--check"]:
sys.exit(0 if recorded == normalized else "The lockfile at `uv.lock` needs to be updated, but `--check` was provided.")
lock_path.write_text(lock_text.replace(f'version = "{recorded}"', f'version = "{normalized}"', 1), encoding="utf-8")
"""

View File

@ -0,0 +1,198 @@
"""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"

View File

@ -158,9 +158,9 @@ These span both layers and require reading multiple files to understand:
- **Long-running MCP** — a durable `McpTaskService` (leased rows, DB as source of truth)
keeps remote task IDs/polling out of the agent loop.
- **Version sources** — a release version must match in `backend/pyproject.toml`,
`frontend/package.json`, and `deploy/helm/deer-flow/Chart.yaml` (`version` + `appVersion`);
pushing a `v*` tag triggers CI that runs `scripts/verify_versions.sh` and blocks all
publishing on drift. See [`RELEASING.md`](../RELEASING.md).
`frontend/package.json`, and `deploy/helm/deer-flow/Chart.yaml` (`version` + `appVersion`),
and `backend/uv.lock` must not be stale; pushing a `v*` tag triggers CI that runs
`scripts/verify_versions.sh` (including `uv lock --check`) and blocks all publishing on drift. See [`RELEASING.md`](../RELEASING.md).
---

View File

@ -5,6 +5,7 @@
# deploy/helm/deer-flow/Chart.yaml — version + appVersion
# backend/pyproject.toml — version
# frontend/package.json — version
# backend/uv.lock — root package version, via `uv lock --check`
#
# Usage:
# scripts/verify_versions.sh # all sources must be mutually equal
@ -13,7 +14,7 @@
# Exit status is 0 when consistent, 1 otherwise. The release workflows
# (.github/workflows/chart.yaml and container.yaml) call this on v* tags — via
# the reusable .github/workflows/verify-versions.yml — to gate publishing when
# a version source was forgotten.
# a version source was forgotten. Requires `uv` on PATH for the lock check.
set -uo pipefail
@ -22,8 +23,9 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CHART="$ROOT/deploy/helm/deer-flow/Chart.yaml"
PYPROJECT="$ROOT/backend/pyproject.toml"
PACKAGE="$ROOT/frontend/package.json"
UV_LOCK="$ROOT/backend/uv.lock"
for f in "$CHART" "$PYPROJECT" "$PACKAGE"; do
for f in "$CHART" "$PYPROJECT" "$PACKAGE" "$UV_LOCK"; do
if [ ! -f "$f" ]; then
echo "::error::missing version file: $f" >&2
exit 1
@ -34,11 +36,20 @@ CHART_VERSION=$(awk '/^version:/ {print $2; exit}' "$CHART")
APP_VERSION=$(awk '/^appVersion:/ {gsub(/"/, ""); print $2; exit}' "$CHART")
PY_VERSION=$(awk -F'"' '/^version[[:space:]]*=/ {print $2; exit}' "$PYPROJECT")
JS_VERSION=$(grep -m1 '"version"' "$PACKAGE" | awk -F'"' '{print $4}')
PY_NAME=$(awk -F'"' '/^name[[:space:]]*=/ {print $2; exit}' "$PYPROJECT")
LOCK_VERSION=$(awk -v name="$PY_NAME" '
/^\[\[package\]\]/ { root = 0 }
$0 == "name = \"" name "\"" { root = 1 }
root && /^version = / { gsub(/"/, "", $3); print $3; exit }
' "$UV_LOCK")
# Display only: `uv lock --check` below is what passes or fails the lock.
[ -n "$LOCK_VERSION" ] || LOCK_VERSION="(root entry not found; 'uv lock --check' is authoritative)"
printf 'Chart.yaml version: %s\n' "$CHART_VERSION"
printf 'Chart.yaml appVersion: %s\n' "$APP_VERSION"
printf 'backend/pyproject.toml: %s\n' "$PY_VERSION"
printf 'frontend/package.json: %s\n' "$JS_VERSION"
printf 'backend/uv.lock: %s\n' "$LOCK_VERSION"
# mismatch <name> <actual> <expected>: prints a GitHub Actions annotation and
# returns 1 when they differ, 0 when equal.
@ -66,6 +77,20 @@ else
mismatch "frontend/package.json" "$JS_VERSION" "$CHART_VERSION" || status=1
fi
# backend/uv.lock records the root package version in uv's PEP 440 form
# (2.1.0-rc0 is stored as 2.1.0rc0), so let uv judge whether it still matches
# pyproject.toml instead of re-implementing the normalization. A stale lock
# passes the checks above, then fails the backend image's `uv sync --locked`
# after the chart and the other images are already published.
if ! command -v uv >/dev/null 2>&1; then
echo "::error::uv is required to check backend/uv.lock; install it from https://docs.astral.sh/uv/." >&2
status=1
elif ! lock_output=$(cd "$ROOT/backend" && uv lock --check 2>&1); then
printf '%s\n' "$lock_output" >&2
echo "::error::'uv lock --check' failed on backend/uv.lock (output above); if the lock is stale, run scripts/bump_version.sh." >&2
status=1
fi
if [ "$status" -ne 0 ]; then
if [ -n "$EXPECTED" ]; then
echo "Tip: run scripts/bump_version.sh $EXPECTED to align all sources." >&2