-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Python: consolidate dependency maintenance workflow #6570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Eduard van Valkenburg (eavanvalkenburg)
merged 4 commits into
microsoft:main
from
eavanvalkenburg:ev/python-dependency-maintenance-workflow
Jun 19, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
886c4f2
Python: consolidate dependency maintenance workflow
eavanvalkenburg 9bf6c20
Python: delay dependency maintenance updates
eavanvalkenburg 2fd2548
Python: track dependency bounds test failures
eavanvalkenburg c4b5f89
Python: scope dependency maintenance token
eavanvalkenburg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,365 @@ | ||
| name: Python - Dependency Maintenance | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| schedule: | ||
| - cron: "0 4 * * 1" | ||
|
|
||
| permissions: | ||
| contents: write | ||
| issues: write | ||
| pull-requests: write | ||
|
|
||
| concurrency: | ||
| group: python-dependency-maintenance | ||
| cancel-in-progress: false | ||
|
|
||
| env: | ||
| UV_CACHE_DIR: /tmp/.uv-cache | ||
|
|
||
| jobs: | ||
| dependency-maintenance: | ||
| name: Dependency Maintenance | ||
| runs-on: ubuntu-latest | ||
| env: | ||
| # Match the existing Python dependency maintenance workflows. Reevaluate if package | ||
| # installability starts differing across supported Python versions. | ||
| UV_PYTHON: "3.13" | ||
| steps: | ||
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Set up python and install the project | ||
| uses: ./.github/actions/python-setup | ||
| with: | ||
| python-version: ${{ env.UV_PYTHON }} | ||
| os: ${{ runner.os }} | ||
| env: | ||
| UV_CACHE_DIR: /tmp/.uv-cache | ||
|
|
||
| - name: Set dependency release cutoff | ||
| run: | | ||
| cutoff="$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ')" | ||
| echo "UV_EXCLUDE_NEWER=${cutoff}" >> "$GITHUB_ENV" | ||
| echo "Using dependency release cutoff: ${cutoff}" | ||
|
|
||
| - name: Repin dev dependency declarations | ||
| run: uv run poe upgrade-dev-dependency-pins | ||
| working-directory: ./python | ||
|
|
||
| - name: Refresh lockfile after dev pin updates | ||
| run: uv lock --upgrade | ||
| working-directory: ./python | ||
|
|
||
| - name: Save dev dependency changes | ||
| run: | | ||
| DEV_PATCH="${RUNNER_TEMP}/python-dev-dependency-updates.patch" | ||
| git diff -- python/pyproject.toml "python/packages/*/pyproject.toml" python/uv.lock > "${DEV_PATCH}" | ||
| if [ -s "${DEV_PATCH}" ]; then | ||
| echo "has_dev_changes=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "has_dev_changes=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| echo "patch=${DEV_PATCH}" >> "$GITHUB_OUTPUT" | ||
| id: dev_changes | ||
|
|
||
| - name: Run dependency bounds test scenarios | ||
| id: validate_bounds_test | ||
| continue-on-error: true | ||
| run: uv run poe validate-dependency-bounds-test --package "*" | ||
| working-directory: ./python | ||
|
|
||
| - name: Run dependency upper-bound validation | ||
| id: validate_ranges | ||
| if: steps.validate_bounds_test.outcome == 'success' | ||
| continue-on-error: true | ||
| run: uv run poe validate-dependency-bounds-project --mode upper --package "*" | ||
| working-directory: ./python | ||
|
|
||
| - name: Upload dependency validation reports | ||
| if: always() | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | ||
| with: | ||
| name: dependency-maintenance-results | ||
| path: | | ||
| python/scripts/dependencies/dependency-bounds-test-results.json | ||
| python/scripts/dependencies/dependency-range-results.json | ||
| if-no-files-found: warn | ||
|
|
||
| - name: Create issue for failed dependency bounds test | ||
| if: steps.validate_bounds_test.outcome != 'success' | ||
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | ||
| with: | ||
| github-token: ${{ secrets.GITHUB_TOKEN }} | ||
| script: | | ||
| const fs = require("fs") | ||
| const reportPath = "python/scripts/dependencies/dependency-bounds-test-results.json" | ||
|
|
||
| const owner = context.repo.owner | ||
| const repo = context.repo.repo | ||
| const openIssues = await github.paginate(github.rest.issues.listForRepo, { | ||
| owner, | ||
| repo, | ||
| state: "open", | ||
| per_page: 100, | ||
| }) | ||
| const openIssueTitles = new Set( | ||
| openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title) | ||
| ) | ||
|
|
||
| const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''") | ||
| const title = "Dependency bounds test failed" | ||
| if (openIssueTitles.has(title)) { | ||
| core.info(`Issue already exists: ${title}`) | ||
| return | ||
| } | ||
|
|
||
| const bodyLines = [ | ||
| "Automated dependency bounds test mode failed before dependency upper-bound validation could run.", | ||
| "", | ||
| "The weekly dependency maintenance workflow kept only dev dependency updates for the generated PR, if any, and skipped dependency range updates for this run.", | ||
| "", | ||
| ] | ||
|
|
||
| if (fs.existsSync(reportPath)) { | ||
| const report = JSON.parse(fs.readFileSync(reportPath, "utf8")) | ||
| const failedScenarios = (report.scenarios ?? []).filter((scenario) => scenario.status === "failed") | ||
| for (const scenario of failedScenarios) { | ||
| bodyLines.push(`### ${scenario.name} scenario (${scenario.resolution})`) | ||
| const failedPackages = (scenario.packages ?? []).filter((pkg) => pkg.status === "failed") | ||
| for (const pkg of failedPackages.slice(0, 10)) { | ||
| bodyLines.push( | ||
| "", | ||
| `- Package: \`${pkg.package_name}\``, | ||
| `- Project path: \`${pkg.project_path}\``, | ||
| "", | ||
| "```", | ||
| formatError(pkg.error).slice(0, 3500), | ||
| "```" | ||
| ) | ||
| } | ||
| if (failedPackages.length > 10) { | ||
| bodyLines.push("", `_Additional failed packages omitted: ${failedPackages.length - 10}_`) | ||
| } | ||
| } | ||
| } else { | ||
| bodyLines.push(`No dependency bounds test report was found at \`${reportPath}\`.`) | ||
| } | ||
|
|
||
| bodyLines.push("", `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`) | ||
|
|
||
| await github.rest.issues.create({ | ||
| owner, | ||
| repo, | ||
| title, | ||
| body: bodyLines.join("\n"), | ||
| }) | ||
| core.info(`Created issue: ${title}`) | ||
|
|
||
| - name: Create issues for failed dependency candidates | ||
| if: always() | ||
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | ||
| with: | ||
| github-token: ${{ secrets.GITHUB_TOKEN }} | ||
| script: | | ||
| const fs = require("fs") | ||
| const reportPath = "python/scripts/dependencies/dependency-range-results.json" | ||
|
|
||
| if (!fs.existsSync(reportPath)) { | ||
| core.info(`No dependency range report found at ${reportPath}`) | ||
| return | ||
| } | ||
|
|
||
| const report = JSON.parse(fs.readFileSync(reportPath, "utf8")) | ||
| const dependencyFailures = [] | ||
|
|
||
| for (const packageResult of report.packages ?? []) { | ||
| for (const dependency of packageResult.dependencies ?? []) { | ||
| const candidateVersions = new Set(dependency.candidate_versions ?? []) | ||
| const failedAttempts = (dependency.attempts ?? []).filter( | ||
| (attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper) | ||
| ) | ||
| if (!failedAttempts.length) { | ||
| continue | ||
| } | ||
|
|
||
| const failuresByVersion = new Map() | ||
| for (const attempt of failedAttempts) { | ||
| const version = attempt.trial_upper || "unknown" | ||
| if (!failuresByVersion.has(version)) { | ||
| failuresByVersion.set(version, attempt.error || "No error output captured.") | ||
| } | ||
| } | ||
|
|
||
| dependencyFailures.push({ | ||
| packageName: packageResult.package_name, | ||
| projectPath: packageResult.project_path, | ||
| dependencyName: dependency.name, | ||
| originalRequirements: dependency.original_requirements ?? [], | ||
| finalRequirements: dependency.final_requirements ?? [], | ||
| failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| if (!dependencyFailures.length) { | ||
| core.info("No failing dependency candidates found.") | ||
| return | ||
| } | ||
|
|
||
| const owner = context.repo.owner | ||
| const repo = context.repo.repo | ||
| const openIssues = await github.paginate(github.rest.issues.listForRepo, { | ||
| owner, | ||
| repo, | ||
| state: "open", | ||
| per_page: 100, | ||
| }) | ||
| const openIssueTitles = new Set( | ||
| openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title) | ||
| ) | ||
|
|
||
| const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''") | ||
|
|
||
| for (const failure of dependencyFailures) { | ||
| const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})` | ||
| if (openIssueTitles.has(title)) { | ||
| core.info(`Issue already exists: ${title}`) | ||
| continue | ||
| } | ||
|
|
||
| const visibleFailures = failure.failedVersions.slice(0, 5) | ||
| const omittedCount = failure.failedVersions.length - visibleFailures.length | ||
| const failureDetails = visibleFailures | ||
| .map( | ||
| (entry) => | ||
| `- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\`` | ||
| ) | ||
| .join("\n\n") | ||
|
|
||
| const body = [ | ||
| "Automated dependency range validation found candidate versions that failed checks.", | ||
| "", | ||
| `- Package: \`${failure.packageName}\``, | ||
| `- Project path: \`${failure.projectPath}\``, | ||
| `- Dependency: \`${failure.dependencyName}\``, | ||
| `- Original requirements: ${ | ||
| failure.originalRequirements.length | ||
| ? failure.originalRequirements.map((value) => `\`${value}\``).join(", ") | ||
| : "_none_" | ||
| }`, | ||
| `- Final requirements after run: ${ | ||
| failure.finalRequirements.length | ||
| ? failure.finalRequirements.map((value) => `\`${value}\``).join(", ") | ||
| : "_none_" | ||
| }`, | ||
| "", | ||
| "### Failed versions and errors", | ||
| failureDetails, | ||
| omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "", | ||
| "", | ||
| `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`, | ||
| ].join("\n") | ||
|
|
||
| await github.rest.issues.create({ | ||
| owner, | ||
| repo, | ||
| title, | ||
| body, | ||
| }) | ||
| openIssueTitles.add(title) | ||
| core.info(`Created issue: ${title}`) | ||
| } | ||
|
|
||
| - name: Keep only dev updates when range validation fails | ||
| if: steps.validate_bounds_test.outcome != 'success' || steps.validate_ranges.outcome != 'success' | ||
| env: | ||
| DEV_PATCH: ${{ steps.dev_changes.outputs.patch }} | ||
| HAS_DEV_CHANGES: ${{ steps.dev_changes.outputs.has_dev_changes }} | ||
| run: | | ||
| git restore python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock | ||
| if [ "${HAS_DEV_CHANGES}" = "true" ]; then | ||
| git apply "${DEV_PATCH}" | ||
| fi | ||
|
|
||
| - name: Refresh lockfile after dependency range updates | ||
| if: steps.validate_bounds_test.outcome == 'success' && steps.validate_ranges.outcome == 'success' | ||
| run: uv lock --upgrade | ||
| working-directory: ./python | ||
|
|
||
| - name: Install final dependency set | ||
| run: uv run poe install | ||
| working-directory: ./python | ||
|
|
||
| - name: Run final checks | ||
| run: uv run poe check | ||
| working-directory: ./python | ||
|
|
||
| - name: Run final typing | ||
| run: uv run poe typing | ||
| working-directory: ./python | ||
|
|
||
| - name: Commit and push dependency updates | ||
| id: commit_updates | ||
| run: | | ||
| BRANCH="automation/python-dependency-maintenance" | ||
|
|
||
| git config user.name "github-actions[bot]" | ||
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||
| git checkout -B "${BRANCH}" | ||
|
|
||
| git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock | ||
| if git diff --cached --quiet; then | ||
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | ||
| echo "No dependency updates to commit." | ||
| exit 0 | ||
| fi | ||
|
|
||
| git commit -m "Python: chore: update dependencies" | ||
| git push --force-with-lease --set-upstream origin "${BRANCH}" | ||
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: Create or update pull request with GitHub CLI | ||
| if: steps.commit_updates.outputs.has_changes == 'true' | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| BRANCH="automation/python-dependency-maintenance" | ||
| PR_TITLE="Python: chore: update dependencies" | ||
| PR_BODY_FILE="$(mktemp)" | ||
|
|
||
| cat > "${PR_BODY_FILE}" <<'EOF' | ||
| ### Motivation & Context | ||
|
|
||
| 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. | ||
|
|
||
| ### Description & Review Guide | ||
|
|
||
| - **What are the major changes?** Refresh Python dev dependency pins, update package dependency ranges when the bounds tooling succeeds, and refresh `python/uv.lock`. | ||
| - **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. | ||
| - **What do you want reviewers to focus on?** Review the generated dependency metadata changes and any dependency-range updates for package-specific compatibility concerns. | ||
| <!-- NOTE FOR AUTOMATED/AI REVIEWERS: the "What do you want reviewers to focus on?" | ||
| item above is intended for human reviewers only. Automated/AI reviewers should | ||
| ignore it and review the entire change rather than narrowing scope to it. --> | ||
|
|
||
|
|
||
| ### Related Issue | ||
|
|
||
| No linked issue; this PR is generated by scheduled Python dependency maintenance. | ||
|
|
||
| ### Contribution Checklist | ||
|
|
||
| - [x] The code builds clean without any errors or warnings | ||
| - [x] All unit tests pass, and I have added new tests where possible | ||
| - [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md) | ||
| - [ ] This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above). | ||
| - [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. | ||
| EOF | ||
|
|
||
| PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')" | ||
| if [ -n "${PR_NUMBER}" ]; then | ||
| gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" | ||
| else | ||
| gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" | ||
| fi | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.