@@ -17,9 +17,10 @@ name: Dependency canary
1717# route around retroactive caps by picking an older uncapped mcp, and the
1818# bot cannot tell which package (or interaction) is at fault. The issue's
1919# "What to do" section is the runbook; a human decides.
20- # - float test tooling (pytest, ruff, pyright, coverage, ...). Those stay at
21- # uv.lock so a pytest major cannot masquerade as an SDK break; Dependabot
22- # owns moving them.
20+ # - float test tooling (pytest, ruff, pyright, coverage, ...). Only the
21+ # runtime closure is upgraded; everything else keeps its uv.lock version as
22+ # a preference and moves only when a floated runtime dependency forces it
23+ # (the report tags such rows "tooling"). Dependabot owns moving tooling.
2324# - test pre-releases on the schedule. `workflow_dispatch` with
2425# `prerelease: true` does that on demand and never files an issue.
2526# - bisect. The issue lists what changed since the last green run (usually
@@ -29,11 +30,13 @@ name: Dependency canary
2930# *dev* dependency caps (e.g. logfire pins opentelemetry-sdk, which pins
3031# opentelemetry-api) cannot reach their newest release here — the issue's "Not
3132# tested at their newest release" section lists them each run. Dependency
32- # groups outside `default-groups` (translate, codegen) are stripped before
33- # resolving so their caps (anthropic: pydantic<3) do not apply.
33+ # groups that `uv sync` does not install (translate, codegen) are stripped
34+ # before resolving so their caps (anthropic: pydantic<3) do not apply.
3435#
3536# Notifications: assignees get the issue traffic. GitHub additionally e-mails
3637# scheduled-run failures only to whoever last edited the `cron:` line below.
38+ # The P0 label is added automatically when every cell hard-fails and is never
39+ # removed automatically; de-escalation is a human call.
3740
3841on :
3942 schedule :
4851 description : " Create/update/close the tracking issue exactly as a scheduled run would"
4952 type : boolean
5053 default : false
54+ # TEMPORARY while this workflow is under review: exercise it on the PR branch.
55+ # Report-only (push runs never touch issues). Remove before merging.
56+ push :
57+ branches : ["ci/dependency-canary"]
5158
5259permissions : {}
5360
5461concurrency :
55- group : ${{ github.workflow }}-${{ github.ref }}
56- cancel-in-progress : true
62+ # One canary at a time, and never cancel one in flight: a half-finished run
63+ # must not be what decides the tracking issue's fate. Runs are short; queue.
64+ group : ${{ github.workflow }}
65+ cancel-in-progress : false
66+
67+ defaults :
68+ run :
69+ shell : bash # -eo pipefail everywhere, and Git-Bash on the Windows cell
5770
5871env :
5972 COLUMNS : 150
6275 # releases (the ruff 0.14.12 incident that got the per-PR "highest" leg
6376 # removed in #1869) and same-day yanks. A weekly job loses nothing by it.
6477 CANARY_LAG : " 24 hours"
78+ # Single source of truth for the test matrix; the report checks every one of
79+ # these produced a result. Oldest and newest supported Python bracket the
80+ # marker forks in the lock (deps drop 3.10 first; 3.14 gets wheels last), and
81+ # Windows/newest is where a fresh release most often lacks a wheel.
82+ CANARY_CELLS : >-
83+ [
84+ {"cell": "ubuntu-3.10", "os": "ubuntu-latest", "python": "3.10", "smoke": "", "pyright": ""},
85+ {"cell": "ubuntu-3.14", "os": "ubuntu-latest", "python": "3.14", "smoke": "1", "pyright": "1"},
86+ {"cell": "windows-3.14", "os": "windows-latest", "python": "3.14", "smoke": "", "pyright": ""}
87+ ]
6588 CANARY_LABEL : dependency-canary
6689 CANARY_ASSIGNEES : " maxisbey,Kludex"
6790
@@ -73,10 +96,19 @@ jobs:
7396 timeout-minutes : 15
7497 permissions :
7598 contents : read
76- actions : read # `gh run list`: find the last green scheduled run to diff against
99+ actions : read # find and download the last green scheduled run's resolution to diff against
77100 outputs :
78- cutoff : ${{ steps.cutoffs .outputs.cutoff }}
101+ cells : ${{ steps.plan .outputs.cells }}
79102 steps :
103+ - name : Publish the test matrix
104+ id : plan
105+ run : |
106+ {
107+ echo 'cells<<EOF'
108+ echo "$CANARY_CELLS"
109+ echo 'EOF'
110+ } >>"$GITHUB_OUTPUT"
111+
80112 - uses : actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
81113 with :
82114 persist-credentials : false
@@ -98,135 +130,133 @@ jobs:
98130 enable-cache : false
99131 version : ${{ env.UV_VERSION }}
100132
101- - name : Compute cutoffs
102- id : cutoffs
133+ - name : Fetch the last green run's resolution
103134 env :
104135 GH_TOKEN : ${{ github.token }}
105136 run : |
106- mkdir -p canary-resolve
107- cutoff=$(date -u -d "-$CANARY_LAG" +%Y-%m-%dT%H:%M:%SZ)
108- echo "cutoff=$cutoff" >>"$GITHUB_OUTPUT"
109- echo "$cutoff" >canary-resolve/cutoff.txt
110- uv self version >canary-resolve/uv-version.txt
111- # Baseline = what the last green *scheduled* run saw (its start time minus the same lag).
112- last_green=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow dependency-canary.yml \
113- --branch main --event schedule --status success --limit 1 --json startedAt --jq '.[0].startedAt // empty')
114- if [ -n "$last_green" ]; then
115- date -u -d "$last_green -$CANARY_LAG" +%Y-%m-%dT%H:%M:%SZ >canary-resolve/baseline.txt
116- else
117- : >canary-resolve/baseline.txt
137+ mkdir -p out/canary-resolve
138+ cd out/canary-resolve
139+ date -u -d "-$CANARY_LAG" +%Y-%m-%dT%H:%M:%SZ >cutoff.txt
140+ (uv self version --short 2>/dev/null || uv --version | awk '{print $2}') >uv-version.txt
141+ : >baseline.txt
142+ # Baseline = the uv.lock the last successful *scheduled* run uploaded, so the
143+ # report can list only what moved since then. Best effort (artifacts expire).
144+ gh run list --repo "$GITHUB_REPOSITORY" --workflow dependency-canary.yml --branch main \
145+ --event schedule --status success --limit 1 --json databaseId,startedAt,url >last-green.json || echo '[]' >last-green.json
146+ run_id=$(jq -r '.[0].databaseId // empty' last-green.json)
147+ if [ -n "$run_id" ] && gh run download "$run_id" --repo "$GITHUB_REPOSITORY" -n canary-resolve -D baseline-dl; then
148+ if [ -f baseline-dl/canary-resolve/uv.lock ]; then
149+ cp baseline-dl/canary-resolve/uv.lock baseline.lock
150+ jq -r '.[0] | "[\(.startedAt[:10]) run](\(.url))"' last-green.json >baseline.txt
151+ fi
118152 fi
119- echo "cutoff=$cutoff baseline=$(cat canary-resolve/baseline.txt)"
153+ rm -rf baseline-dl
154+ echo "cutoff=$(cat cutoff.txt) uv=$(cat uv-version.txt) baseline=$(cat baseline.txt)"
120155
121156 - name : Work out what to float
122157 run : |
158+ r=out/canary-resolve
123159 # The runtime closure of mcp[cli,rich] as currently locked: exactly the set
124160 # `pip install "mcp[cli,rich]"` pulls in. New transitive deps that a newer
125161 # release introduces have no lock entry and so resolve to newest anyway.
126162 uv export --frozen --no-default-groups --all-extras --no-emit-workspace \
127- --no-hashes --no-header --no-annotate | sed -E 's/[=; @].*//' | sort -u >canary-resolve/closure.txt
128- echo "Floating $(wc -l <canary-resolve/closure.txt) packages:"; tr '\n' ' ' <canary-resolve/closure.txt; echo
163+ --no-hashes --no-header --no-annotate | grep -E '^[A-Za-z0-9]' | sed -E 's/[=; @[].*//' | sort -u >$r/closure.txt
164+ if [ ! -s $r/closure.txt ]; then echo "::error::uv export produced an empty runtime closure"; exit 1; fi
165+ echo "Floating $(wc -l <$r/closure.txt) packages:"; tr '\n' ' ' <$r/closure.txt; echo
129166 # Dependency groups that `uv sync` does not install still constrain the
130- # resolution (uv.lock is universal). Strip the non-default ones so e.g. the
131- # translate group's `anthropic` cannot hold pydantic below a new major.
132- python3 - <<'EOF' >canary-resolve /strip.sh
167+ # resolution (uv.lock is universal). Strip them so e.g. the translate
168+ # group's `anthropic` cannot hold pydantic below a new major.
169+ python3 - <<'EOF' >$r /strip.sh
133170 import re, tomllib
134171 project = tomllib.load(open("pyproject.toml", "rb"))
135- keep = set(project.get("tool", {}).get("uv", {}).get("default-groups", []))
136- for group, deps in project.get("dependency-groups", {}).items():
137- names = [re.match(r"[A-Za-z0-9._-]+", d).group(0) for d in deps if isinstance(d, str)]
172+ groups = project.get("dependency-groups", {})
173+ default = project.get("tool", {}).get("uv", {}).get("default-groups", ["dev"])
174+ keep = set(groups) if default == "all" else set(default)
175+ todo = list(keep)
176+ while todo: # groups pulled in via {include-group = "..."} are installed too
177+ for entry in groups.get(todo.pop(), []):
178+ if isinstance(entry, dict) and entry.get("include-group") not in keep | {None}:
179+ keep.add(entry["include-group"])
180+ todo.append(entry["include-group"])
181+ for group, entries in groups.items():
182+ names = [re.match(r"[A-Za-z0-9._-]+", e).group(0) for e in entries if isinstance(e, str)]
138183 if group not in keep and names:
139184 print("uv remove --frozen --group", group, *names)
140185 EOF
141- cat canary-resolve /strip.sh
142- bash -e canary-resolve /strip.sh
186+ cat $r /strip.sh
187+ bash -e $r /strip.sh
143188
144189 - name : Resolve newest allowed versions
145190 env :
146191 PRERELEASE : ${{ inputs.prerelease && 'allow' || '' }}
147192 run : |
148- set -o pipefail
149- args=(--exclude-newer "$(cat canary-resolve/cutoff.txt)")
193+ r=out/canary-resolve
194+ cutoff=$(cat $r/cutoff.txt)
195+ cp uv.lock $r/committed.lock
196+ # Per-package cutoffs rather than a global --exclude-newer, so an exact pin
197+ # elsewhere (docs group, build constraints) bumped the day before the run
198+ # cannot make the resolution fail.
199+ args=()
150200 if [ -n "$PRERELEASE" ]; then args+=(--prerelease "$PRERELEASE"); fi
151- while read -r pkg; do args+=(-P "$pkg"); done <canary-resolve/closure.txt
152- cp uv.lock canary-resolve/committed.lock
153-
154- # Reconstruct what the last green run resolved (same command, its cutoff) so the
155- # report can list only what moved since. Best effort: a failure here just drops that table.
156- baseline=$(cat canary-resolve/baseline.txt)
157- if [ -n "$baseline" ]; then
158- echo "::group::Baseline resolution as of the last green run ($baseline)"
159- base_args=("${args[@]}")
160- base_args[1]=$baseline
161- if uv lock "${base_args[@]}" 2>&1 | tee canary-resolve/baseline.log; then
162- cp uv.lock canary-resolve/baseline.lock
163- else
164- echo "::warning::could not re-resolve the last-green baseline; the report will only diff against uv.lock"
165- : >canary-resolve/baseline.txt
166- fi
167- cp canary-resolve/committed.lock uv.lock
168- echo "::endgroup::"
201+ while read -r pkg; do args+=(-P "$pkg" --exclude-newer-package "$pkg=$cutoff"); done <$r/closure.txt
202+ { printf 'uv lock'; printf ' %q' "${args[@]}"; echo; } >$r/lock-cmd.sh
203+ if uv lock "${args[@]}" 2>&1 | tee $r/lock.log; then
204+ echo ok >$r/lock-status
205+ cp uv.lock $r/uv.lock
206+ else
207+ # Distinguishes "uv ran and could not resolve" from every other way this job can fail.
208+ echo failed >$r/lock-status
209+ exit 1
169210 fi
170211
171- uv lock "${args[@]}" 2>&1 | tee canary-resolve/lock.log
172- cp uv.lock canary-resolve/uv.lock
173-
174212 - name : Summarise what moved
175213 run : |
176- python3 scripts/ci/canary_lock_diff.py canary-resolve/committed.lock uv.lock \
177- --old-label "uv.lock" --new-label "this run" --suspects canary-resolve/suspects-vs-lock.txt >canary-resolve/vs-lock.md
178- if [ -f canary-resolve/baseline.lock ]; then
179- python3 scripts/ci/canary_lock_diff.py canary-resolve/baseline.lock uv.lock \
180- --old-label "last green" --new-label "this run" --suspects canary-resolve/suspects-since-green.txt >canary-resolve/since-green.md
214+ r=out/canary-resolve
215+ python3 scripts/ci/canary_lock_diff.py $r/committed.lock uv.lock \
216+ --old-label "uv.lock" --new-label "this run" --suspects $r/suspects-vs-lock.txt >$r/vs-lock.md
217+ if [ -f $r/baseline.lock ]; then
218+ python3 scripts/ci/canary_lock_diff.py $r/baseline.lock uv.lock \
219+ --old-label "last green" --new-label "this run" --suspects $r/suspects-since-green.txt >$r/since-green.md
181220 fi
182221 # Direct runtime deps that could not reach their newest release (capped by something else in the resolution).
183- uv tree --frozen --outdated --depth 1 --package mcp >canary-resolve /tree.txt 2>/dev/null || true
222+ uv tree --frozen --outdated --universal -- depth 1 --package mcp >$r /tree.txt
184223 {
185224 echo "| Package | Resolved | Latest |"
186225 echo "| --- | --- | --- |"
187- sed -nE 's/^[^A-Za-z0-9]*([A-Za-z0-9._-]+)(\[[^]]*\])? v([^ ]+)( \(extra: [^)]*\))? \(latest: v([^)]+)\)$/| \1 | \3 | \5 |/p' canary-resolve/tree.txt
188- } >canary-resolve/held-back.md
189- if [ "$(wc -l <canary-resolve/held-back.md)" -le 2 ]; then : >canary-resolve/held-back.md; fi
226+ sed -nE 's/^[^A-Za-z0-9]*([A-Za-z0-9._-]+)(\[[^]]*\])? v([^ ]+) .*\(latest: v([^)]+)\)$/\1 \3 \4/p' $r/tree.txt |
227+ sort -u | while read -r name have latest; do
228+ if grep -qxF "$name" $r/closure.txt; then echo "| $name | $have | $latest |"; fi
229+ done
230+ } >$r/held-back.md
231+ if [ "$(wc -l <$r/held-back.md)" -le 2 ]; then : >$r/held-back.md; fi
190232 {
191- echo "## Resolution (cutoff $(cat canary-resolve /cutoff.txt))"
233+ echo "## Resolution (cutoff $(cat $r /cutoff.txt))"
192234 echo
193- 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
194- echo "### vs uv.lock"; cat canary-resolve /vs-lock.md; echo
195- if [ -s canary-resolve /held-back.md ]; then echo "### Held back below latest"; cat canary-resolve /held-back.md; fi
235+ if [ -s $r /since-green.md ]; then echo "### Since last green ($(cat $r /baseline.txt))"; cat $r /since-green.md; echo; fi
236+ echo "### vs uv.lock"; cat $r /vs-lock.md; echo
237+ if [ -s $r /held-back.md ]; then echo "### Held back below latest"; cat $r /held-back.md; fi
196238 } >>"$GITHUB_STEP_SUMMARY"
197239
198240 - name : Upload resolution
199- if : always()
241+ if : ${{ !cancelled() }}
200242 uses : actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
201243 with :
202244 name : canary-resolve
203- path : canary-resolve /
245+ path : out /
204246 retention-days : 90
205247 if-no-files-found : error
206248
207249 test :
208250 name : test (${{ matrix.cell }})
209251 needs : resolve
210252 runs-on : ${{ matrix.os }}
211- timeout-minutes : 20
253+ timeout-minutes : 25
212254 permissions :
213255 contents : read
214256 strategy :
215257 fail-fast : false
216258 matrix :
217- include :
218- # Oldest and newest supported Python bracket the marker forks in the
219- # lock (deps drop 3.10 first; 3.14 gets wheels last). Windows/newest is
220- # where a fresh release most often lacks a wheel, and pywin32 lives there.
221- - { cell: ubuntu-3.10, os: ubuntu-latest, python: "3.10" }
222- - {
223- cell : ubuntu-3.14,
224- os : ubuntu-latest,
225- python : " 3.14" ,
226- smoke : " 1" ,
227- pyright : " 1" ,
228- }
229- - { cell: windows-3.14, os: windows-latest, python: "3.14" }
259+ include : ${{ fromJSON(needs.resolve.outputs.cells) }}
230260 steps :
231261 - uses : actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
232262 with :
@@ -251,10 +281,12 @@ jobs:
251281 uses : actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
252282 with :
253283 name : canary-resolve
254- path : canary-resolve
284+ path : .
255285
256286 - name : Install and test
257- shell : bash
287+ # The suite takes ~2 min; a dependency that makes it hang should surface as
288+ # this cell's "incomplete" status in the report, not as a 25-minute job kill.
289+ timeout-minutes : 15
258290 env :
259291 CANARY_CELL : ${{ matrix.cell }}
260292 CANARY_PYTHON : ${{ matrix.python }}
@@ -265,25 +297,22 @@ jobs:
265297 PYTHONWARNDEFAULTENCODING : " 1"
266298 run : |
267299 cp canary-resolve/uv.lock uv.lock
268- # The lock was resolved with these groups stripped; keep pyproject consistent so --frozen holds.
269- bash -e canary-resolve/strip.sh
270300 bash scripts/ci/canary_cell.sh
271301
272302 - name : Upload cell result
273- if : always()
303+ if : ${{ !cancelled() }}
274304 uses : actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
275305 with :
276306 name : canary-cell-${{ matrix.cell }}
277- path : |
278- canary-out/status
279- canary-out/cell.md
307+ path : out/
280308 retention-days : 30
281309 if-no-files-found : error
282310
283311 report :
284312 needs : [resolve, test]
285- # always(): a red resolve/test job is exactly when this must run.
286- if : always() && github.repository == 'modelcontextprotocol/python-sdk' && needs.resolve.result != 'cancelled' && needs.resolve.result != 'skipped'
313+ # !cancelled(): a red resolve/test job is exactly when this must run, but a
314+ # cancelled run must never decide the issue's fate from partial results.
315+ if : ${{ !cancelled() && github.repository == 'modelcontextprotocol/python-sdk' }}
287316 runs-on : ubuntu-latest
288317 timeout-minutes : 10
289318 permissions :
@@ -299,13 +328,18 @@ jobs:
299328 uses : actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
300329 with :
301330 pattern : canary-*
331+ # Every artifact already carries its own top-level directory, so merging
332+ # yields artifacts/canary-resolve/ and artifacts/canary-cell-*/ no matter
333+ # how many artifacts exist (a lone match would otherwise be flattened).
334+ merge-multiple : true
302335 path : artifacts
303336
304337 - name : Report
305338 env :
306339 GH_TOKEN : ${{ github.token }}
307340 CANARY_ARTIFACTS : artifacts
308341 CANARY_RESOLVE_RESULT : ${{ needs.resolve.result }}
342+ CANARY_TEST_RESULT : ${{ needs.test.result }}
309343 CANARY_RUN_URL : ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
310344 # Only the schedule (or an explicit dispatch asking for it) touches issues, and never a pre-release run.
311345 CANARY_FILE_ISSUES : ${{ !inputs.prerelease && (github.event_name == 'schedule' || inputs.file-issue) && 'true' || 'false' }}
0 commit comments