fix(release): refresh backend/uv.lock when bumping the version (#5859)

* fix(release): refresh backend/uv.lock when bumping the version

`scripts/bump_version.sh` rewrote backend/pyproject.toml,
frontend/package.json and the Helm chart's `version`/`appVersion`, but
`backend/uv.lock` also records the root package version (uv keeps its PEP 440
form, so `2.1.0-rc0` is stored as `2.1.0rc0`). The documented release step left
the lock pinning the previous version, and that is not cosmetic drift:
`uv lock --check` (lint CI) fails on a stale lock and `uv sync --locked`
(e2e / plugin workflows) refuses the tree, so the release commit could not pass
CI. RELEASING.md only mentioned the manual `cd backend && uv lock` step for
release candidates.

The script now refreshes the lock via `uv lock` and refuses to start without
`uv` — it exits before editing anything rather than leaving a half-bumped tree
whose lock CI rejects. `uv lock` stays the authority for the normalized
prerelease form; the lock diff for a bump is a single line. RELEASING.md and
AGENTS.md now describe the lock as part of the bump.

Tests: backend/tests/test_bump_version_lockfile_sync.py (red on main, green
here) pins both halves — the lock moves with the sources, and a missing uv is an
error instead of a silent no-op.

* docs(changelog): record the release bump lockfile fix (#5859)

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
IronMurphy 2026-09-25 20:42:27 +08:00 committed by GitHub
parent 64e499be98
commit 65ed502e54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 313 additions and 13 deletions

View File

@ -231,9 +231,11 @@ These apply repo-wide; module guides own the module-specific detail.
relying on the platform locale.
- **Version sources must stay in lockstep** — a release version must match identically in
`backend/pyproject.toml`, `frontend/package.json`, and `deploy/helm/deer-flow/Chart.yaml`
(`version` + `appVersion`). Pushing a `v*` git tag triggers CI that runs
`scripts/verify_versions.sh` and **blocks all publishing** if any source drifts. Before
bumping a version, run `scripts/bump_version.sh <ver>` (aligns all four at once) and
(`version` + `appVersion`), and `backend/uv.lock` must record the same version for the root
package (uv stores its PEP 440 form, e.g. `2.1.0rc0`). Pushing a `v*` git tag triggers CI
that runs `scripts/verify_versions.sh` and **blocks all publishing** if any source drifts.
Before bumping a version, run `scripts/bump_version.sh <ver>` (aligns the four fields and
refreshes `backend/uv.lock` at once — it needs `uv` on `PATH`) and
`scripts/verify_versions.sh <ver>` to catch drift early. See [RELEASING.md](RELEASING.md).
- **Don't edit `CLAUDE.md`** — it only contains `@AGENTS.md`. All agent guidance changes
belong here in `AGENTS.md`; `CLAUDE.md` is a thin import shim.

View File

@ -2404,6 +2404,57 @@ This release closes that milestone with **772 merged pull requests**.
### Fixed
- **release:** Bumping the version no longer leaves `backend/uv.lock` behind.
`scripts/bump_version.sh` rewrote `backend/pyproject.toml`, `frontend/package.json`
and the Helm chart, but the lockfile records the root package's own version too
(uv keeps its PEP 440 form, so `2.1.0-rc0` is stored as `2.1.0rc0`). The
documented release step therefore produced a commit whose lock CI rejects:
`uv lock --check` fails on the stale lock and `uv sync --locked` refuses the
tree, and with pre-commit installed it broke a step earlier on the
`uv-lock-check` hook. The script now refreshes the lock with `uv lock` and exits
before editing anything when `uv` is missing, instead of leaving a half-bumped
working tree behind. Only the root package's version line moves. ([#5859])
- **config:** A `config.yaml` edit that lands while the previous edit is still
being loaded is no longer lost until the next edit. `get_app_config()`'s
loader parsed the file and then hashed it again to record the cache
signature, so a write between those two reads left the cache holding the
older content under the newer content's signature — a state the signature
comparison can never detect. The loader now reads the file once and signs
the bytes it parsed; a write that races the load just triggers one more
reload on the next call. ([#5848])
- **config:** `request_admission.requests_per_minute` and `max_queue_size` now
accept `$VAR` environment references like every other field. Both are strict
integers so a bool or float is still rejected, but `$VAR` substitution always
produces a string, so `requests_per_minute: $RPM` failed the whole config load
with "Input should be a valid integer" even when `RPM=60`. A decimal literal
delivered as a string is now converted before the strict check; any other
string is still rejected. ([#5838])
- **scheduler:** Pausing a scheduled task no longer loses the pause when a
dispatch is in flight on SQLite. `release_dispatch_lease` guards on the lease
owner — which pausing clears — but read the row without taking SQLite's
writer, so a stale read passed the guard and wrote the task back to
`enabled` with `next_run_at` untouched, leaving the scheduler firing a task
the API had reported as paused. The read now takes the writer first, as every
other mutating path in that repository does. PostgreSQL was unaffected.
([#5777])
- **mcp:** Lazy MCP initialization no longer runs tool discovery twice when
discovery itself raises a `RuntimeError` such as `McpTaskConfigurationError`.
The `asyncio.run` fallback in `get_cached_mcp_tools()` was meant only for
`get_event_loop()` failing, but it also caught discovery errors and
re-spawned every stdio server (and re-fetched OAuth tokens) before giving
up; inside a running loop it also logged a misleading "asyncio.run() cannot
be called from a running event loop" traceback instead of the real cause.
- **uploads:** Deleting an uploaded document no longer deletes the converted
Markdown beside it. Conversion names a companion after the document's stem
and falls back to a `_N` suffix when that name is taken, so the `.md` next to
a document can belong to another document sharing the stem, or to the user:
uploading `a.docx` and `a.pdf` produced `a.md` and `a_1.md`, and deleting
`a.pdf` destroyed `a.docx`'s companion. Companions now survive their
document, stay listed, and can be deleted on their own. ([#5673])
- **subagents:** Recognize zero-byte regular deliverables in remote sandbox
acceptance checks. Readable empty files now satisfy `exists` and
`file_written` and deterministically fail `non-empty`, instead of remaining
UNVERIFIED. ([#5559])
- **frontend:** Keep the `…` (kebab) menu on project chat rows inside the
sidebar. In the sidebar's grouped Projects mode the indented nested menus
kept `SidebarMenu`'s `w-full` while carrying an extra `ml-4`, so they were
@ -6026,3 +6077,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5844]: https://github.com/bytedance/deer-flow/pull/5844
[#5845]: https://github.com/bytedance/deer-flow/pull/5845
[#5848]: https://github.com/bytedance/deer-flow/pull/5848
[#5859]: https://github.com/bytedance/deer-flow/pull/5859

View File

@ -2120,6 +2120,46 @@
### 修复
- **发布:** 版本升级不再把 `backend/uv.lock` 落下。`scripts/bump_version.sh` 会改写
`backend/pyproject.toml`、`frontend/package.json` 与 Helm chart,但 lockfile 同样记录了
根包自身的版本(uv 保留其 PEP 440 形式,因此 `2.1.0-rc0` 存为 `2.1.0rc0`),于是文档给出的
发布步骤产出的提交会被 lock 相关 CI 拦下:`uv lock --check` 判其过期,`uv sync --locked`
也会拒绝该工作区;装了 pre-commit 时还会更早在 `uv-lock-check` 钩子上失败。现在该脚本会用
`uv lock` 刷新 lockfile,并在缺少 `uv` 时于修改任何文件之前退出,而不是留下一个只改一半的
工作区。实际改动仅涉及根包的那一行版本号。([#5859])
- **配置:** 在上一次编辑仍在加载时落盘的 `config.yaml` 编辑,不再要等到下一次编辑才生效。
`get_app_config()` 的加载器先解析文件,再重新读取一遍来计算缓存签名,因此夹在两次读取
之间的写入会让缓存以较新内容的签名保存较旧的内容,而签名比较永远无法发现这种状态。
现在加载器只读取文件一次,并对解析的那份字节计算签名;与加载竞争的写入只会在下一次
调用时多触发一次重载。([#5848])
- **配置:** `request_admission.requests_per_minute` 与 `max_queue_size` 现在与其他字段一样
接受 `$VAR` 环境变量引用。这两个字段是严格整数,布尔值与浮点数仍会被拒绝;但 `$VAR`
替换得到的永远是字符串,因此即使 `RPM=60`,`requests_per_minute: $RPM` 也会让整个配置
加载失败并报 "Input should be a valid integer"。现在以字符串形式到达的十进制整数字面量会在
严格校验之前被转换;其他字符串仍会被拒绝。([#5838])
- **调度器:** 在 SQLite 上,调度分发进行中暂停计划任务时不再丢失暂停状态。
`release_dispatch_lease` 依据租约持有者做校验(暂停会清除该字段),但读取任务行时没有先获取
SQLite 的写锁,因此过期的读取会通过校验,并把任务状态写回 `enabled` 且不改动 `next_run_at`,
导致接口已回复“已暂停”的任务仍被继续触发。现在该读取会像该仓储中其他写入路径一样先获取写锁。
PostgreSQL 不受影响。([#5777])
- **mcp:** MCP 延迟初始化在工具发现本身抛出 `RuntimeError`(例如
`McpTaskConfigurationError`)时,不再把发现流程跑两遍。`get_cached_mcp_tools()` 里的
`asyncio.run` 兜底只为 `get_event_loop()` 失败而设,却同时捕获了发现阶段的错误,于是在
放弃之前会重新拉起每一个 stdio 服务器(并重新获取 OAuth 令牌);在运行中的事件循环里,
它记录的还是误导性的 "asyncio.run() cannot be called from a running event loop"
堆栈,而不是真正的原因。
- **上传:** 删除已上传的文档时,不再连带删除其旁边转换生成的 Markdown。转换以文档主干名
命名配套文件,名称被占用时回退为 `_N` 后缀,因此文档旁的 `.md` 可能属于主干名相同的另一个
文档,或属于用户自己:上传 `a.docx` 与 `a.pdf` 会生成 `a.md` 与 `a_1.md`,删除 `a.pdf`
却会销毁 `a.docx` 的配套文件。现在配套文件会保留、继续出现在列表中,可单独删除。([#5673])
- **nginx:** 把 600 秒读取超时扩展到其余两个会等待 Gateway 的 location,它们在线程路由的修复
之后仍沿用 nginx 默认的 60 秒。`/api/` 兜底 location 之后:无状态的 `POST /api/runs/wait`
阻塞在同一套运行完成等待上,并在客户端断开时取消该运行,因此等待超过 60 秒的 API 调用方会
同时收到 504 **并且**运行被取消;输入框的 `POST /api/input-polish` 则等待一次性模型调用。
`/api/skills` 之后:安装 `.skill` 压缩包会对其中每个文件各做一次 LLM 安全扫描,自定义技能的
编辑与回滚各再做一次,它们都没有自己的超时;此前只有同级的 `/api/skills/install/upload`
拿到了更长的超时,因此同样的安装经由 `POST /api/skills/install` 会在 60 秒失败。
Docker、本地开发与 Helm 配置均已应用。([#5524])
- **前端:** 项目会话行上的 `…`(kebab)菜单不再溢出侧边栏。在侧边栏的按项目分组模式下,
缩进的嵌套菜单继承了 `SidebarMenu` 的 `w-full` 又额外带着 `ml-4`,实际宽度是"100% + 16px",
其绝对定位的 `right-1` 操作按钮因此落到侧边栏边缘之外——活跃项目分组下的行被裁剪,Archived
@ -5135,3 +5175,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5844]: https://github.com/bytedance/deer-flow/pull/5844
[#5845]: https://github.com/bytedance/deer-flow/pull/5845
[#5848]: https://github.com/bytedance/deer-flow/pull/5848
[#5859]: https://github.com/bytedance/deer-flow/pull/5859

View File

@ -32,8 +32,11 @@ distinguishes it from a release.
## Helper scripts
- `scripts/bump_version.sh <version>` — set all four fields at once, then
self-verify. Tolerates a leading `v` (e.g. `v2.1.0`).
- `scripts/bump_version.sh <version>` — set all four fields at once, refresh
`backend/uv.lock`, then self-verify. Tolerates a leading `v` (e.g. `v2.1.0`).
Needs `uv` on `PATH` (`backend/uv.lock` pins the root package version too, and
lint CI runs `uv lock --check`); the script fails before editing anything when
`uv` is missing.
```bash
scripts/bump_version.sh 2.1.0
```
@ -47,7 +50,8 @@ distinguishes it from a release.
## Release procedure
1. **Bump the version** across all sources:
1. **Bump the version** across all sources (this refreshes
`backend/uv.lock` too):
```bash
scripts/bump_version.sh 2.1.0
```
@ -74,13 +78,13 @@ distinguishes it from a release.
Release-candidate tags must include the same prerelease suffix in all four
version fields. For example, before tagging `v2.1.0-rc0`, run
`bash scripts/bump_version.sh 2.1.0-rc0`, refresh `backend/uv.lock` with
`cd backend && uv lock`, and run `bash scripts/verify_versions.sh 2.1.0-rc0`
from the repository root. Commit the version and lockfile changes before
creating the tag. Python lockfiles normalize this version to `2.1.0rc0`;
the source version fields checked by the release gate retain `2.1.0-rc0`.
Re-running a failed workflow on an unchanged tag does not pick up a later
version-fix commit.
`bash scripts/bump_version.sh 2.1.0-rc0` and run
`bash scripts/verify_versions.sh 2.1.0-rc0` from the repository root. Commit
the version and lockfile changes before creating the tag. Python lockfiles
normalize this version to `2.1.0rc0` — `bump_version.sh` leaves the normalizing
to `uv lock` — while the source version fields checked by the release gate
retain `2.1.0-rc0`. Re-running a failed workflow on an unchanged tag does not
pick up a later version-fix commit.
## What CI publishes on a `v*` tag
@ -148,6 +152,10 @@ 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.
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.
When it fails, the job annotation names the offending file and suggests the
fix:

View File

@ -0,0 +1,175 @@
"""Regression test: ``scripts/bump_version.sh`` must refresh ``backend/uv.lock``.
``RELEASING.md`` tells releasers that step 1 of a release is
``scripts/bump_version.sh <version>`` — "set all four fields at once, then
self-verify" — and ``verify_versions.sh`` agrees that the tree is consistent
afterwards. But ``backend/uv.lock`` records the root package's own version too
(uv normalizes ``2.1.0-rc0`` to ``2.1.0rc0``), and the script only rewrote
``pyproject.toml`` / ``package.json`` / ``Chart.yaml``. The documented release
step therefore left the lock pinned to the *previous* version.
Measured on ``3a862780`` (``main``, version ``2.1.0-rc0``) before this fix::
$ scripts/bump_version.sh 2.2.0 # documented step, exits 0, self-verifies
$ sed -n '/name = "deer-flow"/,+1p' backend/uv.lock
name = "deer-flow"
version = "2.1.0rc0" # still the old version
$ cd backend && uv lock --check # .github/workflows/lint-check.yml:59
error: The lockfile at `uv.lock` needs to be updated, but `--check` was provided.
The release commit that the script claims is verified turns PR CI red, and
``uv sync --locked`` (``e2e-tests.yml``, ``jev-plugin-package.yml``) refuses the
tree. ``RELEASING.md`` only mentions the manual ``cd backend && uv lock`` step in
the pre-release section, so a normal release hit this every time.
The script now refreshes the lock itself and refuses to run without ``uv`` —
better an early error than a half-bumped tree whose lock CI rejects.
"""
from __future__ import annotations
import os
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
from support.shell import find_script_bash
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT_BASH = find_script_bash()
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",
)
# Minimal versions of the three files bump_version.sh rewrites, plus the lock
# entry uv writes for the root ("virtual") package. Only the fields the script
# touches are present, so a stray rewrite shows up as a diff.
PYPROJECT = '[project]\nname = "deer-flow"\nversion = "2.1.0-rc0"\n'
PACKAGE_JSON = '{\n "name": "deer-flow-frontend",\n "version": "2.1.0-rc0",\n "private": true\n}\n'
CHART = 'apiVersion: v2\nname: deer-flow\nversion: 2.1.0-rc0\nappVersion: "2.1.0-rc0"\n'
UV_LOCK = 'version = 1\n\n[[package]]\nname = "deer-flow"\nversion = "2.1.0rc0"\nsource = { virtual = "." }\n'
# 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.
FAKE_UV = """#!/usr/bin/env python3
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("uv " + " ".join(sys.argv[1:]) + "\\n")
args = sys.argv[1:]
if args != ["lock"]:
sys.exit(f"fake uv: unsupported invocation {args}")
root = Path.cwd()
lock_path = root / "uv.lock"
lock_text = lock_path.read_text(encoding="utf-8")
pyproject = (root / "pyproject.toml").read_text(encoding="utf-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)
lock_path.write_text(lock_text.replace(f'version = "{recorded}"', f'version = "{normalized}"', 1), encoding="utf-8")
"""
def _write_sandbox(root: Path) -> Path:
"""Materialize a repo-shaped tree the scripts can run against."""
(root / "scripts").mkdir(parents=True)
(root / "backend").mkdir()
(root / "frontend").mkdir()
(root / "deploy" / "helm" / "deer-flow").mkdir(parents=True)
for name in ("bump_version.sh", "verify_versions.sh"):
shutil.copy2(REPO_ROOT / "scripts" / name, root / "scripts" / name)
(root / "backend" / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8")
(root / "backend" / "uv.lock").write_text(UV_LOCK, encoding="utf-8")
(root / "frontend" / "package.json").write_text(PACKAGE_JSON, encoding="utf-8")
(root / "deploy" / "helm" / "deer-flow" / "Chart.yaml").write_text(CHART, encoding="utf-8")
return root
def _install_fake_uv(root: Path) -> Path:
"""Put a fake ``uv`` and the log it appends to on 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_bump(root: Path, version: str, path_prefix: Path | None = None, path: str | None = None) -> subprocess.CompletedProcess[str]:
env = dict(os.environ)
if path is not None:
env["PATH"] = path
elif path_prefix is not None:
env["PATH"] = f"{path_prefix}{os.pathsep}{env.get('PATH', '')}"
return subprocess.run(
[SCRIPT_BASH, str(root / "scripts" / "bump_version.sh"), version],
cwd=root,
env=env,
capture_output=True,
text=True,
check=False,
)
def _lock_version(root: Path) -> str:
lock = (root / "backend" / "uv.lock").read_text(encoding="utf-8")
return lock.split('version = "', 2)[1].split('"', 1)[0]
def test_bump_version_refreshes_the_lockfile(tmp_path: Path) -> None:
"""After the documented release step the lock records the new version."""
root = _write_sandbox(tmp_path / "repo")
bin_dir = _install_fake_uv(root)
result = _run_bump(root, "2.2.0", path_prefix=bin_dir)
assert result.returncode == 0, result.stderr
assert _lock_version(root) == "2.2.0", "backend/uv.lock still pins the previous version"
assert (root / "backend" / "pyproject.toml").read_text(encoding="utf-8") == PYPROJECT.replace("2.1.0-rc0", "2.2.0")
invocations = (bin_dir / "uv-calls.log").read_text(encoding="utf-8").splitlines()
assert invocations and all(line.startswith("uv lock") for line in invocations), f"bump_version.sh never invoked uv lock: {invocations}"
def test_bump_version_normalizes_a_prerelease_into_the_lock(tmp_path: Path) -> None:
"""A prerelease keeps its `-rc` form in the sources and PEP 440 in the lock."""
root = _write_sandbox(tmp_path / "repo")
bin_dir = _install_fake_uv(root)
result = _run_bump(root, "2.1.0-rc1", path_prefix=bin_dir)
assert result.returncode == 0, result.stderr
assert (root / "backend" / "pyproject.toml").read_text(encoding="utf-8") == PYPROJECT.replace("2.1.0-rc0", "2.1.0-rc1")
assert _lock_version(root) == "2.1.0rc1", "uv normalizes the prerelease in the lock, and the script must let it"
def test_bump_version_refuses_to_run_without_uv(tmp_path: Path) -> None:
"""Without uv the script fails before touching anything, instead of half-bumping."""
root = _write_sandbox(tmp_path / "repo")
# Keep every PATH entry that does not provide a `uv`, so the script still
# finds awk/grep/sed/python3 and can only fail on the missing uv itself.
without_uv = [entry for entry in os.environ.get("PATH", "").split(os.pathsep) if entry and not (Path(entry) / "uv").exists()]
path = os.pathsep.join(without_uv)
assert shutil.which("uv", path=path) is None, "test setup: uv is still reachable on PATH"
assert shutil.which("awk", path=path) is not None, "test setup: awk must stay reachable"
result = _run_bump(root, "2.2.0", path=path)
assert result.returncode != 0, "a missing uv must be an error: the lock would stay stale"
assert "uv" in (result.stderr + result.stdout)
assert (root / "backend" / "pyproject.toml").read_text(encoding="utf-8") == PYPROJECT, "the script edited files before checking for uv"
assert _lock_version(root) == "2.1.0rc0"

View File

@ -6,9 +6,13 @@
#
# Updates:
# backend/pyproject.toml (version = "...")
# backend/uv.lock (root package version, via `uv lock`)
# frontend/package.json ("version": "...")
# deploy/helm/deer-flow/Chart.yaml (version: + appVersion:)
#
# Requires `uv` on PATH: backend/uv.lock pins the root package version too, and
# lint CI runs `uv lock --check`, so the lock has to move with the sources.
#
# This does NOT edit CHANGELOG.md or create/push a git tag — keep those manual.
# After running, commit and tag v<version> to trigger the release workflows
# (container.yaml + chart.yaml), which gate on scripts/verify_versions.sh.
@ -39,6 +43,17 @@ for f in "$PYPROJECT" "$PACKAGE" "$CHART"; do
fi
done
# backend/uv.lock records the root package's version as well (uv normalizes a
# prerelease, so `2.1.0-rc0` is stored as `2.1.0rc0`). Leaving it behind is not
# a cosmetic drift: `uv lock --check` in lint CI fails on the stale lock, and
# `uv sync --locked` refuses the tree — so the release step would produce a
# commit whose own CI is red. Refresh it here, and fail before editing anything
# when uv is missing rather than leaving a half-bumped tree behind.
if ! command -v uv >/dev/null 2>&1; then
echo "error: 'uv' is required to refresh backend/uv.lock in lockstep; install it from https://docs.astral.sh/uv/ and re-run" >&2
exit 1
fi
python3 - "$PYPROJECT" "$PACKAGE" "$CHART" "$VERSION" <<'PY'
import re
import sys
@ -79,8 +94,14 @@ with open(chart, "w") as f:
f.write(new)
PY
if ! (cd "$ROOT/backend" && uv lock); then
echo "error: 'uv lock' failed; backend/uv.lock still pins the previous version. Fix the resolution error and re-run, or revert the version edits above." >&2
exit 1
fi
echo "Bumped version to $VERSION in:"
echo " backend/pyproject.toml"
echo " backend/uv.lock"
echo " frontend/package.json"
echo " deploy/helm/deer-flow/Chart.yaml (version + appVersion)"
echo