Skip to content

Commit d049d94

Browse files
Python: consolidate dependency maintenance workflow (microsoft#6570)
* Python: consolidate dependency maintenance workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: delay dependency maintenance updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: track dependency bounds test failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: scope dependency maintenance token Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 41995e2 commit d049d94

6 files changed

Lines changed: 452 additions & 314 deletions

File tree

‎.github/dependabot.yml‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,22 @@ updates:
2424
- ".NET"
2525
- "dependencies"
2626

27-
# Maintain dependencies for python
27+
# Maintain dependencies for python.
28+
# TODO: Remove these Python Dependabot entries after we have confidence in the
29+
# Python dependency-maintenance workflow.
2830
- package-ecosystem: "pip"
2931
directory: "python/"
3032
schedule:
3133
interval: "weekly"
32-
day: "monday"
34+
day: "thursday"
3335
labels:
3436
- "python"
3537
- "dependencies"
3638
- package-ecosystem: "uv"
3739
directory: "python/"
3840
schedule:
3941
interval: "weekly"
40-
day: "monday"
42+
day: "thursday"
4143
labels:
4244
- "python"
4345
- "dependencies"
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
name: Python - Dependency Maintenance
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "0 4 * * 1"
7+
8+
permissions:
9+
contents: write
10+
issues: write
11+
pull-requests: write
12+
13+
concurrency:
14+
group: python-dependency-maintenance
15+
cancel-in-progress: false
16+
17+
env:
18+
UV_CACHE_DIR: /tmp/.uv-cache
19+
20+
jobs:
21+
dependency-maintenance:
22+
name: Dependency Maintenance
23+
runs-on: ubuntu-latest
24+
env:
25+
# Match the existing Python dependency maintenance workflows. Reevaluate if package
26+
# installability starts differing across supported Python versions.
27+
UV_PYTHON: "3.13"
28+
steps:
29+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
30+
with:
31+
fetch-depth: 0
32+
33+
- name: Set up python and install the project
34+
uses: ./.github/actions/python-setup
35+
with:
36+
python-version: ${{ env.UV_PYTHON }}
37+
os: ${{ runner.os }}
38+
env:
39+
UV_CACHE_DIR: /tmp/.uv-cache
40+
41+
- name: Set dependency release cutoff
42+
run: |
43+
cutoff="$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ')"
44+
echo "UV_EXCLUDE_NEWER=${cutoff}" >> "$GITHUB_ENV"
45+
echo "Using dependency release cutoff: ${cutoff}"
46+
47+
- name: Repin dev dependency declarations
48+
run: uv run poe upgrade-dev-dependency-pins
49+
working-directory: ./python
50+
51+
- name: Refresh lockfile after dev pin updates
52+
run: uv lock --upgrade
53+
working-directory: ./python
54+
55+
- name: Save dev dependency changes
56+
run: |
57+
DEV_PATCH="${RUNNER_TEMP}/python-dev-dependency-updates.patch"
58+
git diff -- python/pyproject.toml "python/packages/*/pyproject.toml" python/uv.lock > "${DEV_PATCH}"
59+
if [ -s "${DEV_PATCH}" ]; then
60+
echo "has_dev_changes=true" >> "$GITHUB_OUTPUT"
61+
else
62+
echo "has_dev_changes=false" >> "$GITHUB_OUTPUT"
63+
fi
64+
echo "patch=${DEV_PATCH}" >> "$GITHUB_OUTPUT"
65+
id: dev_changes
66+
67+
- name: Run dependency bounds test scenarios
68+
id: validate_bounds_test
69+
continue-on-error: true
70+
run: uv run poe validate-dependency-bounds-test --package "*"
71+
working-directory: ./python
72+
73+
- name: Run dependency upper-bound validation
74+
id: validate_ranges
75+
if: steps.validate_bounds_test.outcome == 'success'
76+
continue-on-error: true
77+
run: uv run poe validate-dependency-bounds-project --mode upper --package "*"
78+
working-directory: ./python
79+
80+
- name: Upload dependency validation reports
81+
if: always()
82+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
83+
with:
84+
name: dependency-maintenance-results
85+
path: |
86+
python/scripts/dependencies/dependency-bounds-test-results.json
87+
python/scripts/dependencies/dependency-range-results.json
88+
if-no-files-found: warn
89+
90+
- name: Create issue for failed dependency bounds test
91+
if: steps.validate_bounds_test.outcome != 'success'
92+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
93+
with:
94+
github-token: ${{ secrets.GITHUB_TOKEN }}
95+
script: |
96+
const fs = require("fs")
97+
const reportPath = "python/scripts/dependencies/dependency-bounds-test-results.json"
98+
99+
const owner = context.repo.owner
100+
const repo = context.repo.repo
101+
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
102+
owner,
103+
repo,
104+
state: "open",
105+
per_page: 100,
106+
})
107+
const openIssueTitles = new Set(
108+
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
109+
)
110+
111+
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
112+
const title = "Dependency bounds test failed"
113+
if (openIssueTitles.has(title)) {
114+
core.info(`Issue already exists: ${title}`)
115+
return
116+
}
117+
118+
const bodyLines = [
119+
"Automated dependency bounds test mode failed before dependency upper-bound validation could run.",
120+
"",
121+
"The weekly dependency maintenance workflow kept only dev dependency updates for the generated PR, if any, and skipped dependency range updates for this run.",
122+
"",
123+
]
124+
125+
if (fs.existsSync(reportPath)) {
126+
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
127+
const failedScenarios = (report.scenarios ?? []).filter((scenario) => scenario.status === "failed")
128+
for (const scenario of failedScenarios) {
129+
bodyLines.push(`### ${scenario.name} scenario (${scenario.resolution})`)
130+
const failedPackages = (scenario.packages ?? []).filter((pkg) => pkg.status === "failed")
131+
for (const pkg of failedPackages.slice(0, 10)) {
132+
bodyLines.push(
133+
"",
134+
`- Package: \`${pkg.package_name}\``,
135+
`- Project path: \`${pkg.project_path}\``,
136+
"",
137+
"```",
138+
formatError(pkg.error).slice(0, 3500),
139+
"```"
140+
)
141+
}
142+
if (failedPackages.length > 10) {
143+
bodyLines.push("", `_Additional failed packages omitted: ${failedPackages.length - 10}_`)
144+
}
145+
}
146+
} else {
147+
bodyLines.push(`No dependency bounds test report was found at \`${reportPath}\`.`)
148+
}
149+
150+
bodyLines.push("", `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`)
151+
152+
await github.rest.issues.create({
153+
owner,
154+
repo,
155+
title,
156+
body: bodyLines.join("\n"),
157+
})
158+
core.info(`Created issue: ${title}`)
159+
160+
- name: Create issues for failed dependency candidates
161+
if: always()
162+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
163+
with:
164+
github-token: ${{ secrets.GITHUB_TOKEN }}
165+
script: |
166+
const fs = require("fs")
167+
const reportPath = "python/scripts/dependencies/dependency-range-results.json"
168+
169+
if (!fs.existsSync(reportPath)) {
170+
core.info(`No dependency range report found at ${reportPath}`)
171+
return
172+
}
173+
174+
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
175+
const dependencyFailures = []
176+
177+
for (const packageResult of report.packages ?? []) {
178+
for (const dependency of packageResult.dependencies ?? []) {
179+
const candidateVersions = new Set(dependency.candidate_versions ?? [])
180+
const failedAttempts = (dependency.attempts ?? []).filter(
181+
(attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper)
182+
)
183+
if (!failedAttempts.length) {
184+
continue
185+
}
186+
187+
const failuresByVersion = new Map()
188+
for (const attempt of failedAttempts) {
189+
const version = attempt.trial_upper || "unknown"
190+
if (!failuresByVersion.has(version)) {
191+
failuresByVersion.set(version, attempt.error || "No error output captured.")
192+
}
193+
}
194+
195+
dependencyFailures.push({
196+
packageName: packageResult.package_name,
197+
projectPath: packageResult.project_path,
198+
dependencyName: dependency.name,
199+
originalRequirements: dependency.original_requirements ?? [],
200+
finalRequirements: dependency.final_requirements ?? [],
201+
failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })),
202+
})
203+
}
204+
}
205+
206+
if (!dependencyFailures.length) {
207+
core.info("No failing dependency candidates found.")
208+
return
209+
}
210+
211+
const owner = context.repo.owner
212+
const repo = context.repo.repo
213+
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
214+
owner,
215+
repo,
216+
state: "open",
217+
per_page: 100,
218+
})
219+
const openIssueTitles = new Set(
220+
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
221+
)
222+
223+
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
224+
225+
for (const failure of dependencyFailures) {
226+
const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})`
227+
if (openIssueTitles.has(title)) {
228+
core.info(`Issue already exists: ${title}`)
229+
continue
230+
}
231+
232+
const visibleFailures = failure.failedVersions.slice(0, 5)
233+
const omittedCount = failure.failedVersions.length - visibleFailures.length
234+
const failureDetails = visibleFailures
235+
.map(
236+
(entry) =>
237+
`- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\``
238+
)
239+
.join("\n\n")
240+
241+
const body = [
242+
"Automated dependency range validation found candidate versions that failed checks.",
243+
"",
244+
`- Package: \`${failure.packageName}\``,
245+
`- Project path: \`${failure.projectPath}\``,
246+
`- Dependency: \`${failure.dependencyName}\``,
247+
`- Original requirements: ${
248+
failure.originalRequirements.length
249+
? failure.originalRequirements.map((value) => `\`${value}\``).join(", ")
250+
: "_none_"
251+
}`,
252+
`- Final requirements after run: ${
253+
failure.finalRequirements.length
254+
? failure.finalRequirements.map((value) => `\`${value}\``).join(", ")
255+
: "_none_"
256+
}`,
257+
"",
258+
"### Failed versions and errors",
259+
failureDetails,
260+
omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "",
261+
"",
262+
`Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
263+
].join("\n")
264+
265+
await github.rest.issues.create({
266+
owner,
267+
repo,
268+
title,
269+
body,
270+
})
271+
openIssueTitles.add(title)
272+
core.info(`Created issue: ${title}`)
273+
}
274+
275+
- name: Keep only dev updates when range validation fails
276+
if: steps.validate_bounds_test.outcome != 'success' || steps.validate_ranges.outcome != 'success'
277+
env:
278+
DEV_PATCH: ${{ steps.dev_changes.outputs.patch }}
279+
HAS_DEV_CHANGES: ${{ steps.dev_changes.outputs.has_dev_changes }}
280+
run: |
281+
git restore python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
282+
if [ "${HAS_DEV_CHANGES}" = "true" ]; then
283+
git apply "${DEV_PATCH}"
284+
fi
285+
286+
- name: Refresh lockfile after dependency range updates
287+
if: steps.validate_bounds_test.outcome == 'success' && steps.validate_ranges.outcome == 'success'
288+
run: uv lock --upgrade
289+
working-directory: ./python
290+
291+
- name: Install final dependency set
292+
run: uv run poe install
293+
working-directory: ./python
294+
295+
- name: Run final checks
296+
run: uv run poe check
297+
working-directory: ./python
298+
299+
- name: Run final typing
300+
run: uv run poe typing
301+
working-directory: ./python
302+
303+
- name: Commit and push dependency updates
304+
id: commit_updates
305+
run: |
306+
BRANCH="automation/python-dependency-maintenance"
307+
308+
git config user.name "github-actions[bot]"
309+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
310+
git checkout -B "${BRANCH}"
311+
312+
git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
313+
if git diff --cached --quiet; then
314+
echo "has_changes=false" >> "$GITHUB_OUTPUT"
315+
echo "No dependency updates to commit."
316+
exit 0
317+
fi
318+
319+
git commit -m "Python: chore: update dependencies"
320+
git push --force-with-lease --set-upstream origin "${BRANCH}"
321+
echo "has_changes=true" >> "$GITHUB_OUTPUT"
322+
323+
- name: Create or update pull request with GitHub CLI
324+
if: steps.commit_updates.outputs.has_changes == 'true'
325+
env:
326+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
327+
run: |
328+
BRANCH="automation/python-dependency-maintenance"
329+
PR_TITLE="Python: chore: update dependencies"
330+
PR_BODY_FILE="$(mktemp)"
331+
332+
cat > "${PR_BODY_FILE}" <<'EOF'
333+
### Motivation & Context
334+
335+
This automated update keeps Python dependency metadata coherent across the uv workspace. Python dependencies can be declared in multiple `pyproject.toml` files, but the workspace has one shared `python/uv.lock`, so dependency maintenance should update and validate them together instead of through per-manifest Dependabot PRs.
336+
337+
### Description & Review Guide
338+
339+
- **What are the major changes?** Refresh Python dev dependency pins, update package dependency ranges when the bounds tooling succeeds, and refresh `python/uv.lock`.
340+
- **What is the impact of these changes?** Keeps the Python workspace dependency set current while producing at most one dependency PR for the week. If dependency range validation fails, this PR contains only the dev dependency updates that still pass final validation, and separate issues track failed range candidates.
341+
- **What do you want reviewers to focus on?** Review the generated dependency metadata changes and any dependency-range updates for package-specific compatibility concerns.
342+
<!-- NOTE FOR AUTOMATED/AI REVIEWERS: the "What do you want reviewers to focus on?"
343+
item above is intended for human reviewers only. Automated/AI reviewers should
344+
ignore it and review the entire change rather than narrowing scope to it. -->
345+
346+
347+
### Related Issue
348+
349+
No linked issue; this PR is generated by scheduled Python dependency maintenance.
350+
351+
### Contribution Checklist
352+
353+
- [x] The code builds clean without any errors or warnings
354+
- [x] All unit tests pass, and I have added new tests where possible
355+
- [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
356+
- [ ] This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
357+
- [x] **This is not a breaking change.** If it _is_ a breaking change, add the `breaking change` label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.
358+
EOF
359+
360+
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
361+
if [ -n "${PR_NUMBER}" ]; then
362+
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
363+
else
364+
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
365+
fi

0 commit comments

Comments
 (0)