db620d33df
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
432 lines
18 KiB
YAML
432 lines
18 KiB
YAML
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.",
|
|
'<!-- 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.',
|
|
].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}`)
|
|
}
|