From db9b0b4bf08eee6654359d705750fb590160565f Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Sat, 10 Oct 2026 15:05:35 +0900 Subject: [PATCH] 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. --- .github/workflows/chart.yaml | 5 +- .github/workflows/container.yaml | 5 +- .github/workflows/verify-versions.yml | 11 +- CHANGELOG.md | 12 ++ CHANGELOG_zh.md | 9 + RELEASING.md | 16 +- .../tests/test_bump_version_lockfile_sync.py | 9 +- .../tests/test_verify_versions_lockfile.py | 198 ++++++++++++++++++ docs/ARCHITECTURE.md | 6 +- scripts/verify_versions.sh | 29 ++- 10 files changed, 280 insertions(+), 20 deletions(-) create mode 100644 backend/tests/test_verify_versions_lockfile.py diff --git a/.github/workflows/chart.yaml b/.github/workflows/chart.yaml index b975e54687..4bfc2d4876 100644 --- a/.github/workflows/chart.yaml +++ b/.github/workflows/chart.yaml @@ -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 diff --git a/.github/workflows/container.yaml b/.github/workflows/container.yaml index 98bf835cbb..3f3c217220 100644 --- a/.github/workflows/container.yaml +++ b/.github/workflows/container.yaml @@ -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: diff --git a/.github/workflows/verify-versions.yml b/.github/workflows/verify-versions.yml index eadb285e72..c5ae6ef401 100644 --- a/.github/workflows/verify-versions.yml +++ b/.github/workflows/verify-versions.yml @@ -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}" diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b3579ef2..01d720fbf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 9334891d11..3f04c78d86 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -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 diff --git a/RELEASING.md b/RELEASING.md index 5e7ec3dbae..4a8759566b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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: diff --git a/backend/tests/test_bump_version_lockfile_sync.py b/backend/tests/test_bump_version_lockfile_sync.py index 10500fc9a2..b2b1b9576a 100644 --- a/backend/tests/test_bump_version_lockfile_sync.py +++ b/backend/tests/test_bump_version_lockfile_sync.py @@ -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") """ diff --git a/backend/tests/test_verify_versions_lockfile.py b/backend/tests/test_verify_versions_lockfile.py new file mode 100644 index 0000000000..d7905cde79 --- /dev/null +++ b/backend/tests/test_verify_versions_lockfile.py @@ -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" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6bdc6b7003..40cc5bfe0b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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). --- diff --git a/scripts/verify_versions.sh b/scripts/verify_versions.sh index e85dbf0b92..e3d1959371 100755 --- a/scripts/verify_versions.sh +++ b/scripts/verify_versions.sh @@ -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 : 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