diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index a5c0b6f9ba47b..967e566878fcd 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -33,6 +33,9 @@ outputs: deps: description: Check pyproject.toml dependency upper bounds. value: ${{ steps.classify.outputs.deps }} + uv_lock: + description: Run `uv lock --check` (pyproject.toml / uv.lock changes only). + value: ${{ steps.classify.outputs.uv_lock }} npm_lock: description: Post/update the semantic package-lock.json diff PR comment. value: ${{ steps.classify.outputs.npm_lock }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aa601e264fe8..b5df97cf020c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,7 @@ jobs: site: ${{ steps.classify.outputs.site }} scan: ${{ steps.classify.outputs.scan }} deps: ${{ steps.classify.outputs.deps }} + uv_lock: ${{ steps.classify.outputs.uv_lock }} npm_lock: ${{ steps.classify.outputs.npm_lock }} installer: ${{ steps.classify.outputs.installer }} docker_meta: ${{ steps.classify.outputs.docker_meta }} @@ -143,6 +144,12 @@ jobs: uv-lockfile: name: Check uv.lock needs: detect + # Gated: `uv lock --check` re-resolves the whole dependency graph against + # PyPI, so on every PR it spent a network round-trip — and, on a registry + # blip, a blocking red X — for diffs that cannot desync the lockfile + # (docs, frontend, prose). Only pyproject.toml / uv.lock can. A + # `.github/` change still forces it on via the classifier's fail-open. + if: needs.detect.outputs.uv_lock == 'true' uses: ./.github/workflows/uv-lockfile-check.yml infographic-check: diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index 3349f932907f1..d258ad731d0ef 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -89,16 +89,46 @@ jobs: # uv lock --check re-resolves against PyPI (network). Retry so a # registry blip doesn't read as "lockfile stale". A genuinely stale # lockfile fails all attempts (deterministic), costing only seconds. + # + # Backoff rather than a flat 10s: three attempts inside ~20s all + # land in the same blip. 5/15/45s spans ~65s instead. + # + # Network failure and a real desync are also reported differently. + # uv says "Request failed after N retries" when it can't reach the + # registry, versus "lockfile needs to be updated" when the lock is + # genuinely stale. Only the second is the contributor's to fix, so + # an unreachable registry says so instead of sending them to + # `uv lock` with nothing to regenerate. ok=false + net_fail=false + delays=(5 15 45) for i in 1 2 3; do - if uv lock --check; then + if uv lock --check >lock-check.log 2>&1; then ok=true + net_fail=false + cat lock-check.log + break + fi + cat lock-check.log + if grep -qiE "Request failed after|Failed to fetch|error sending request|connection (reset|closed)|timed out" lock-check.log; then + net_fail=true + else + # Deterministic failure (a real desync) — retrying just prints + # the same error twice more. + net_fail=false break fi [ "$i" = 3 ] && break - echo "::warning::uv lock --check failed (attempt $i); retrying in 10s" - sleep 10 + echo "::warning::uv lock --check could not reach the registry (attempt $i); retrying in ${delays[$((i-1))]}s" + sleep "${delays[$((i-1))]}" done + if [ "$ok" != true ] && [ "$net_fail" = true ]; then + echo "::error title=uv.lock check could not reach PyPI::Registry unreachable after 3 attempts — infrastructure failure, not a stale lockfile. Re-run the job." + review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock check could not reach PyPI","summary":"`uv lock --check` could not reach the package registry after 3 attempts. This is an infrastructure failure, not a stale lockfile.","how_to_fix":"Re-run the failed job. No change to `uv.lock` is needed."}]}]' + echo "review_status=${review_status}" >> "$GITHUB_OUTPUT" + echo "review_status=${review_status}" > review-status.json + exit 1 + fi if [ "$ok" != true ]; then cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" ## ❌ uv.lock is out of sync with pyproject.toml diff --git a/scripts/ci/classify_changes.py b/scripts/ci/classify_changes.py index 4428a5822f856..3e781233bc016 100644 --- a/scripts/ci/classify_changes.py +++ b/scripts/ci/classify_changes.py @@ -18,6 +18,9 @@ Lanes: * ``site`` — Docusaurus + generated skill docs. * ``scan`` — supply-chain scan (Python files, .pth, setup hooks). * ``deps`` — pyproject.toml dependency bounds check. +* ``uv_lock`` — ``uv lock --check``. Re-resolves the whole graph against + PyPI, so a diff that touches neither ``pyproject.toml`` nor ``uv.lock`` + must not run it. * ``npm_lock`` — semantic package-lock.json diff PR comment. * ``installer`` — PowerShell installer tests (Windows runner). * ``mcp_catalog`` — bundled MCP catalog / installer review. @@ -132,6 +135,7 @@ def classify(files: list[str]) -> dict[str, bool]: "site": any(f.startswith(_SITE) for f in files), "scan": any(_is_scan(f) for f in files), "deps": any(f == "pyproject.toml" for f in files), + "uv_lock": any(f in ("pyproject.toml", "uv.lock") for f in files), "npm_lock": any(f.split("/")[-1] == "package-lock.json" for f in files), "installer": any(_is_installer(f) for f in files), "mcp_catalog": any(_is_mcp_catalog(f) for f in files), @@ -145,6 +149,7 @@ def classify(files: list[str]) -> dict[str, bool]: ret["site"] = True ret["scan"] = True ret["deps"] = True + ret["uv_lock"] = True ret["npm_lock"] = True ret["installer"] = True ret["ci_review"] = True diff --git a/tests/ci/test_classify_changes.py b/tests/ci/test_classify_changes.py index ee5cb8eb10dfb..94ec212ee6c3c 100644 --- a/tests/ci/test_classify_changes.py +++ b/tests/ci/test_classify_changes.py @@ -29,6 +29,7 @@ DEFAULT = { "site": True, "scan": True, "deps": True, + "uv_lock": True, "npm_lock": True, "installer": True, "mcp_catalog": False, @@ -36,7 +37,7 @@ DEFAULT = { } -def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm_lock=False, installer=False, mcp_catalog=False, docker_meta=False, ci_review=False, python_prod=None) -> dict[str, bool]: +def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, uv_lock=False, npm_lock=False, installer=False, mcp_catalog=False, docker_meta=False, ci_review=False, python_prod=None) -> dict[str, bool]: # python_prod tracks python except for tests-only diffs; default it to # python so the majority of cases don't need to spell it out. return { @@ -47,6 +48,7 @@ def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm "site": site, "scan": scan, "deps": deps, + "uv_lock": uv_lock, "npm_lock": npm_lock, "installer": installer, "mcp_catalog": mcp_catalog, @@ -57,14 +59,19 @@ def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm CASES = { "docs-only → nothing heavy": (["README.md", "docs/guide.md"], _lanes()), "python source → python": (["run_agent.py"], _lanes(python=True, scan=True)), - "dep manifest → python": (["pyproject.toml"], _lanes(python=True, scan=True, deps=True)), - "uv.lock → python": (["uv.lock"], _lanes(python=True)), + "dep manifest → python": (["pyproject.toml"], _lanes(python=True, scan=True, deps=True, uv_lock=True)), + "uv.lock → python": (["uv.lock"], _lanes(python=True, uv_lock=True)), "ts package → frontend": (["apps/desktop/src/app.tsx"], _lanes(frontend=True)), "ui-tui → frontend": (["ui-tui/src/entry.ts"], _lanes(frontend=True)), # Lockfile bump shifts every TS package's tree, but not the Python suite. "root lockfile → frontend, not python": (["package-lock.json"], _lanes(frontend=True, npm_lock=True)), "nested lockfile → npm_lock": (["website/package-lock.json"], _lanes(site=True, npm_lock=True)), "website → site": (["website/docs/intro.md"], _lanes(site=True)), + # uv lock --check re-resolves against PyPI, so it must stay off for any + # diff that can't desync the lockfile — a registry blip on a docs PR + # otherwise shows up as a blocking "uv.lock out of sync" red X. + "docs → no uv_lock": (["website/docs/user-guide/profiles.md"], _lanes(site=True)), + "frontend → no uv_lock": (["apps/desktop/src/store/profile.ts"], _lanes(frontend=True)), # SKILL.md reads like docs, but the skill-doc tests read skills/, so a # skill edit must still run Python. "skill md → python + site": (["skills/github/SKILL.md"], _lanes(python=True, site=True)),