name: Python - Dependency Maintenance on: workflow_dispatch: schedule: - cron: "0 4 * * 1" permissions: contents: write issues: 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 "DEPENDENCY_RELEASE_CUTOFF=${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 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 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 dependency maintenance tracking issue if: steps.commit_updates.outputs.has_changes == 'true' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const branch = "automation/python-dependency-maintenance" const prTitle = "Python: chore: update dependencies" const issueTitle = "Python dependency maintenance PR needed" const owner = context.repo.owner const repo = context.repo.repo const branchRef = await github.rest.git.getRef({ owner, repo, ref: `heads/${branch}`, }) const branchSha = branchRef.data.object.sha const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}` const compareUrl = `${context.serverUrl}/${owner}/${repo}/compare/main...${branch}` const prBody = [ "### 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.", '", "", "", "### 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.', ].join("\n") const prBodyFence = "```" const command = [ "PR_BODY_FILE=\"$(mktemp)\"", `cat > "$PR_BODY_FILE" <<'EOF'`, prBody, "EOF", "gh pr create --repo microsoft/agent-framework --base main \\", ` --head ${owner}:${branch} \\`, ` --title "${prTitle}" \\`, " --body-file \"$PR_BODY_FILE\"", ].join("\n") const issueBody = [ "The Python dependency maintenance workflow generated and validated dependency updates, then pushed them to the automation branch.", "", `- Branch: \`${branch}\``, `- Commit: \`${branchSha}\``, `- Compare: ${compareUrl}`, `- Workflow run: ${runUrl}`, "", "GitHub Actions is not permitted to create pull requests in this repository, so a maintainer needs to create the PR manually.", "", "### Create the PR", "", "```bash", command, "```", "", "### Generated PR body", "", prBodyFence, prBody, prBodyFence, ].join("\n") const openIssues = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: "open", per_page: 100, }) const existingIssue = openIssues.find((issue) => !issue.pull_request && issue.title === issueTitle) if (existingIssue) { await github.rest.issues.update({ owner, repo, issue_number: existingIssue.number, title: issueTitle, body: issueBody, }) core.info(`Updated issue #${existingIssue.number}: ${issueTitle}`) } else { const createdIssue = await github.rest.issues.create({ owner, repo, title: issueTitle, body: issueBody, }) core.info(`Created issue #${createdIssue.data.number}: ${issueTitle}`) }