ci: run CI on uv 0.11.33 #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Dependency canary | |
| # Weekly: re-resolve the runtime dependencies of `mcp[cli,rich]` to the newest | |
| # versions our (floors-only) specifiers allow, ignoring uv.lock, run the test | |
| # suite against them, and keep ONE tracking issue in sync with the result — | |
| # opened (and assigned) when newest-allowed breaks, refreshed weekly while it | |
| # stays broken, closed automatically once it passes again. | |
| # | |
| # Why this exists: users who `pip install mcp` get the newest release of every | |
| # dependency the day it ships, while PR CI only ever sees uv.lock (`locked`) and | |
| # the floors (`lowest-direct`). This is deliberately NOT part of PR CI, so an | |
| # upstream release can never turn an unrelated PR red; the price is up to a | |
| # week of latency, which the incident history says is fine. | |
| # | |
| # What it does not do, on purpose: | |
| # - open a PR adding a ceiling. A cap only helps once released, resolvers | |
| # route around retroactive caps by picking an older uncapped mcp, and the | |
| # bot cannot tell which package (or interaction) is at fault. The issue's | |
| # "What to do" section is the runbook; a human decides. | |
| # - float test tooling (pytest, ruff, pyright, coverage, ...). Those stay at | |
| # uv.lock so a pytest major cannot masquerade as an SDK break; Dependabot | |
| # owns moving them. | |
| # - test pre-releases on the schedule. `workflow_dispatch` with | |
| # `prerelease: true` does that on demand and never files an issue. | |
| # - bisect. The issue lists what changed since the last green run (usually | |
| # one to three packages) and the one-line command to pin a suspect back. | |
| # | |
| # Known blind spots: Python 3.11-3.13 and macOS are not run; runtime deps that a | |
| # *dev* dependency caps (e.g. logfire pins opentelemetry-sdk, which pins | |
| # opentelemetry-api) cannot reach their newest release here — the issue's "Not | |
| # tested at their newest release" section lists them each run. Dependency | |
| # groups outside `default-groups` (translate, codegen) are stripped before | |
| # resolving so their caps (anthropic: pydantic<3) do not apply. | |
| # | |
| # Notifications: assignees get the issue traffic. GitHub additionally e-mails | |
| # scheduled-run failures only to whoever last edited the `cron:` line below. | |
| on: | |
| schedule: | |
| - cron: "23 5 * * 1" # Mondays 05:23 UTC | |
| workflow_dispatch: | |
| inputs: | |
| prerelease: | |
| description: "Also consider pre-releases (investigative run; never files an issue)" | |
| type: boolean | |
| default: false | |
| file-issue: | |
| description: "Create/update/close the tracking issue exactly as a scheduled run would" | |
| type: boolean | |
| default: false | |
| # TEMPORARY while this workflow is under review: exercise it on the PR branch. | |
| # Report-only (push runs never touch issues). Remove before merging. | |
| push: | |
| branches: ["ci/dependency-canary"] | |
| permissions: {} | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| COLUMNS: 150 | |
| UV_VERSION: "0.11.33" | |
| # Releases younger than this are invisible to the run: skips half-uploaded | |
| # releases (the ruff 0.14.12 incident that got the per-PR "highest" leg | |
| # removed in #1869) and same-day yanks. A weekly job loses nothing by it. | |
| CANARY_LAG: "24 hours" | |
| CANARY_LABEL: dependency-canary | |
| CANARY_ASSIGNEES: "maxisbey,Kludex" | |
| jobs: | |
| resolve: | |
| # Don't run the schedule on forks. | |
| if: github.repository == 'modelcontextprotocol/python-sdk' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| actions: read # `gh run list`: find the last green scheduled run to diff against | |
| outputs: | |
| cutoff: ${{ steps.cutoffs.outputs.cutoff }} | |
| steps: | |
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | |
| with: | |
| persist-credentials: false | |
| # setup-uv's manifest fetch is a single request with a hard 5s timeout | |
| # (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry. | |
| - name: Install uv | |
| id: setup-uv | |
| continue-on-error: true | |
| uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 | |
| with: | |
| enable-cache: false | |
| version: ${{ env.UV_VERSION }} | |
| - name: Install uv (retry) | |
| if: steps.setup-uv.outcome == 'failure' | |
| uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 | |
| with: | |
| enable-cache: false | |
| version: ${{ env.UV_VERSION }} | |
| - name: Compute cutoffs | |
| id: cutoffs | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| mkdir -p canary-resolve | |
| cutoff=$(date -u -d "-$CANARY_LAG" +%Y-%m-%dT%H:%M:%SZ) | |
| echo "cutoff=$cutoff" >>"$GITHUB_OUTPUT" | |
| echo "$cutoff" >canary-resolve/cutoff.txt | |
| uv self version >canary-resolve/uv-version.txt | |
| # Baseline = what the last green *scheduled* run saw (its start time minus the same lag). | |
| last_green=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow dependency-canary.yml \ | |
| --branch main --event schedule --status success --limit 1 --json startedAt --jq '.[0].startedAt // empty') | |
| if [ -n "$last_green" ]; then | |
| date -u -d "$last_green -$CANARY_LAG" +%Y-%m-%dT%H:%M:%SZ >canary-resolve/baseline.txt | |
| else | |
| : >canary-resolve/baseline.txt | |
| fi | |
| echo "cutoff=$cutoff baseline=$(cat canary-resolve/baseline.txt)" | |
| - name: Work out what to float | |
| run: | | |
| # The runtime closure of mcp[cli,rich] as currently locked: exactly the set | |
| # `pip install "mcp[cli,rich]"` pulls in. New transitive deps that a newer | |
| # release introduces have no lock entry and so resolve to newest anyway. | |
| uv export --frozen --no-default-groups --all-extras --no-emit-workspace \ | |
| --no-hashes --no-header --no-annotate | sed -E 's/[=; @].*//' | sort -u >canary-resolve/closure.txt | |
| echo "Floating $(wc -l <canary-resolve/closure.txt) packages:"; tr '\n' ' ' <canary-resolve/closure.txt; echo | |
| # Dependency groups that `uv sync` does not install still constrain the | |
| # resolution (uv.lock is universal). Strip the non-default ones so e.g. the | |
| # translate group's `anthropic` cannot hold pydantic below a new major. | |
| python3 - <<'EOF' >canary-resolve/strip.sh | |
| import re, tomllib | |
| project = tomllib.load(open("pyproject.toml", "rb")) | |
| keep = set(project.get("tool", {}).get("uv", {}).get("default-groups", [])) | |
| for group, deps in project.get("dependency-groups", {}).items(): | |
| names = [re.match(r"[A-Za-z0-9._-]+", d).group(0) for d in deps if isinstance(d, str)] | |
| if group not in keep and names: | |
| print("uv remove --frozen --group", group, *names) | |
| EOF | |
| cat canary-resolve/strip.sh | |
| bash -e canary-resolve/strip.sh | |
| - name: Resolve newest allowed versions | |
| env: | |
| PRERELEASE: ${{ inputs.prerelease && 'allow' || '' }} | |
| run: | | |
| set -o pipefail | |
| args=(--exclude-newer "$(cat canary-resolve/cutoff.txt)") | |
| if [ -n "$PRERELEASE" ]; then args+=(--prerelease "$PRERELEASE"); fi | |
| while read -r pkg; do args+=(-P "$pkg"); done <canary-resolve/closure.txt | |
| cp uv.lock canary-resolve/committed.lock | |
| # Reconstruct what the last green run resolved (same command, its cutoff) so the | |
| # report can list only what moved since. Best effort: a failure here just drops that table. | |
| baseline=$(cat canary-resolve/baseline.txt) | |
| if [ -n "$baseline" ]; then | |
| echo "::group::Baseline resolution as of the last green run ($baseline)" | |
| base_args=("${args[@]}") | |
| base_args[1]=$baseline | |
| if uv lock "${base_args[@]}" 2>&1 | tee canary-resolve/baseline.log; then | |
| cp uv.lock canary-resolve/baseline.lock | |
| else | |
| echo "::warning::could not re-resolve the last-green baseline; the report will only diff against uv.lock" | |
| : >canary-resolve/baseline.txt | |
| fi | |
| cp canary-resolve/committed.lock uv.lock | |
| echo "::endgroup::" | |
| fi | |
| uv lock "${args[@]}" 2>&1 | tee canary-resolve/lock.log | |
| cp uv.lock canary-resolve/uv.lock | |
| - name: Summarise what moved | |
| run: | | |
| python3 scripts/ci/canary_lock_diff.py canary-resolve/committed.lock uv.lock \ | |
| --old-label "uv.lock" --new-label "this run" --suspects canary-resolve/suspects-vs-lock.txt >canary-resolve/vs-lock.md | |
| if [ -f canary-resolve/baseline.lock ]; then | |
| python3 scripts/ci/canary_lock_diff.py canary-resolve/baseline.lock uv.lock \ | |
| --old-label "last green" --new-label "this run" --suspects canary-resolve/suspects-since-green.txt >canary-resolve/since-green.md | |
| fi | |
| # Direct runtime deps that could not reach their newest release (capped by something else in the resolution). | |
| uv tree --frozen --outdated --depth 1 --package mcp >canary-resolve/tree.txt 2>/dev/null || true | |
| { | |
| echo "| Package | Resolved | Latest |" | |
| echo "| --- | --- | --- |" | |
| sed -nE 's/^[^A-Za-z0-9]*([A-Za-z0-9._-]+)(\[[^]]*\])? v([^ ]+)( \(extra: [^)]*\))? \(latest: v([^)]+)\)$/| \1 | \3 | \5 |/p' canary-resolve/tree.txt | |
| } >canary-resolve/held-back.md | |
| if [ "$(wc -l <canary-resolve/held-back.md)" -le 2 ]; then : >canary-resolve/held-back.md; fi | |
| { | |
| echo "## Resolution (cutoff $(cat canary-resolve/cutoff.txt))" | |
| echo | |
| if [ -s canary-resolve/since-green.md ]; then echo "### Since last green ($(cat canary-resolve/baseline.txt))"; cat canary-resolve/since-green.md; echo; fi | |
| echo "### vs uv.lock"; cat canary-resolve/vs-lock.md; echo | |
| if [ -s canary-resolve/held-back.md ]; then echo "### Held back below latest"; cat canary-resolve/held-back.md; fi | |
| } >>"$GITHUB_STEP_SUMMARY" | |
| - name: Upload resolution | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: canary-resolve | |
| path: canary-resolve/ | |
| retention-days: 90 | |
| if-no-files-found: error | |
| test: | |
| name: test (${{ matrix.cell }}) | |
| needs: resolve | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # Oldest and newest supported Python bracket the marker forks in the | |
| # lock (deps drop 3.10 first; 3.14 gets wheels last). Windows/newest is | |
| # where a fresh release most often lacks a wheel, and pywin32 lives there. | |
| - { cell: ubuntu-3.10, os: ubuntu-latest, python: "3.10" } | |
| - { | |
| cell: ubuntu-3.14, | |
| os: ubuntu-latest, | |
| python: "3.14", | |
| smoke: "1", | |
| pyright: "1", | |
| } | |
| - { cell: windows-3.14, os: windows-latest, python: "3.14" } | |
| steps: | |
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | |
| with: | |
| persist-credentials: false | |
| - name: Install uv | |
| id: setup-uv | |
| continue-on-error: true | |
| uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 | |
| with: | |
| enable-cache: false | |
| version: ${{ env.UV_VERSION }} | |
| - name: Install uv (retry) | |
| if: steps.setup-uv.outcome == 'failure' | |
| uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 | |
| with: | |
| enable-cache: false | |
| version: ${{ env.UV_VERSION }} | |
| - name: Fetch the resolved lock | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: canary-resolve | |
| path: canary-resolve | |
| - name: Install and test | |
| shell: bash | |
| env: | |
| CANARY_CELL: ${{ matrix.cell }} | |
| CANARY_PYTHON: ${{ matrix.python }} | |
| CANARY_PYRIGHT: ${{ matrix.pyright }} | |
| # Same switches as PR CI: real stdio/uvicorn subprocess smoke tests on one | |
| # cell, and PEP 597 EncodingWarnings surfaced (as an env var so xdist workers inherit it). | |
| MCP_EXAMPLES_SMOKE: ${{ matrix.smoke }} | |
| PYTHONWARNDEFAULTENCODING: "1" | |
| run: | | |
| cp canary-resolve/uv.lock uv.lock | |
| # The lock was resolved with these groups stripped; keep pyproject consistent so --frozen holds. | |
| bash -e canary-resolve/strip.sh | |
| bash scripts/ci/canary_cell.sh | |
| - name: Upload cell result | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: canary-cell-${{ matrix.cell }} | |
| path: | | |
| canary-out/status | |
| canary-out/cell.md | |
| retention-days: 30 | |
| if-no-files-found: error | |
| report: | |
| needs: [resolve, test] | |
| # always(): a red resolve/test job is exactly when this must run. | |
| if: always() && github.repository == 'modelcontextprotocol/python-sdk' && needs.resolve.result != 'cancelled' && needs.resolve.result != 'skipped' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read # checkout, for scripts/ci/canary_report.sh | |
| issues: write # open / refresh / close the tracking issue | |
| steps: | |
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | |
| with: | |
| persist-credentials: false | |
| sparse-checkout: scripts/ci | |
| - name: Collect results | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| pattern: canary-* | |
| path: artifacts | |
| - name: Report | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| CANARY_ARTIFACTS: artifacts | |
| CANARY_RESOLVE_RESULT: ${{ needs.resolve.result }} | |
| CANARY_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| # Only the schedule (or an explicit dispatch asking for it) touches issues, and never a pre-release run. | |
| CANARY_FILE_ISSUES: ${{ !inputs.prerelease && (github.event_name == 'schedule' || inputs.file-issue) && 'true' || 'false' }} | |
| run: bash scripts/ci/canary_report.sh |