Skip to content

Commit 00f5e50

Browse files
committed
ci: make the canary report reason over the planned matrix
Review feedback, taken as one structural change rather than patches: the report classified whatever artifacts happened to arrive, so a cell that timed out or lost its runner simply vanished (two passing cells read as green and would have closed a live incident), a cancelled run could still write to the issue, and any resolve-job failure was reported as "cannot be resolved". Now the matrix is defined once and published by the resolve job, the report checks every planned cell against `needs.test.result`, a cell writes a provisional status before doing anything, `uv lock` records whether it was the thing that failed, and incomplete runs are their own class that comments on an open incident instead of rewriting or closing it. Artifacts carry their own top-level directory and are merged on download, so the layout no longer depends on how many of them exist. Smaller corrections from the same review: per-package cutoffs instead of a global --exclude-newer (a freshly bumped exact pin elsewhere could otherwise fail the resolution); the since-last-green diff uses the lock the last green run actually uploaded rather than re-resolving today's tree at an old cutoff; the issue's reproduce block is the literal commands the job ran (group strip included) under `uvx uv@<version>`; per-cell output is size-bounded at the source and the full report goes to the step summary before any truncation; the flake/deprecation re-runs key on whether pytest recorded failures rather than on its exit code; titles say "nothing changed since last green" when that is the case; bash runs with pipefail everywhere; the group-strip generator follows include-group and default-groups = "all"; wording says tooling is preferred at uv.lock, not frozen. The temporary branch trigger is back for one more end-to-end run. No-Verification-Needed: CI-only change (workflow + scripts/ci); scripts exercised locally, workflow verified by a branch run
1 parent 02dfbf5 commit 00f5e50

4 files changed

Lines changed: 340 additions & 245 deletions

File tree

.github/workflows/dependency-canary.yml

Lines changed: 130 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -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

3841
on:
3942
schedule:
@@ -48,12 +51,22 @@ on:
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

5259
permissions: {}
5360

5461
concurrency:
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

5871
env:
5972
COLUMNS: 150
@@ -62,6 +75,16 @@ env:
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

Comments
 (0)