|
| 1 | +# Probe the highest allowed dependency versions, then open issues/PRs from the passing updates. |
| 2 | +name: Python - Dependency Range Validation |
| 3 | + |
| 4 | +on: |
| 5 | + workflow_dispatch: |
| 6 | + |
| 7 | +permissions: |
| 8 | + contents: write |
| 9 | + issues: write |
| 10 | + pull-requests: write |
| 11 | + |
| 12 | +env: |
| 13 | + UV_CACHE_DIR: /tmp/.uv-cache |
| 14 | + |
| 15 | +jobs: |
| 16 | + dependency-range-validation: |
| 17 | + name: Dependency Range Validation |
| 18 | + runs-on: ubuntu-latest |
| 19 | + env: |
| 20 | + # For now only run 3.13, if we do encounter situations where there are mismatches between packages and python versions (other then 3.10 and 3.14 which are known to not be able to install everything) |
| 21 | + # then we will have to reevaluate. |
| 22 | + UV_PYTHON: "3.13" |
| 23 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 24 | + steps: |
| 25 | + - uses: actions/checkout@v6 |
| 26 | + with: |
| 27 | + fetch-depth: 0 |
| 28 | + |
| 29 | + - name: Set up python and install the project |
| 30 | + uses: ./.github/actions/python-setup |
| 31 | + with: |
| 32 | + python-version: ${{ env.UV_PYTHON }} |
| 33 | + os: ${{ runner.os }} |
| 34 | + env: |
| 35 | + UV_CACHE_DIR: /tmp/.uv-cache |
| 36 | + |
| 37 | + - name: Run dependency range validation |
| 38 | + id: validate_ranges |
| 39 | + # Keep workflow running so we can still publish diagnostics from this run. |
| 40 | + continue-on-error: true |
| 41 | + run: uv run poe validate-dependency-bounds-project --mode upper --project "*" |
| 42 | + working-directory: ./python |
| 43 | + |
| 44 | + - name: Upload dependency range report |
| 45 | + # Always publish the report so failures are inspectable even when validation fails. |
| 46 | + if: always() |
| 47 | + uses: actions/upload-artifact@v4 |
| 48 | + with: |
| 49 | + name: dependency-range-results |
| 50 | + path: python/scripts/dependencies/dependency-range-results.json |
| 51 | + if-no-files-found: warn |
| 52 | + |
| 53 | + - name: Create issues for failed dependency candidates |
| 54 | + # Always process the report so failed candidates create actionable tracking issues. |
| 55 | + if: always() |
| 56 | + uses: actions/github-script@v8 |
| 57 | + with: |
| 58 | + script: | |
| 59 | + const fs = require("fs") |
| 60 | + const reportPath = "python/scripts/dependencies/dependency-range-results.json" |
| 61 | +
|
| 62 | + if (!fs.existsSync(reportPath)) { |
| 63 | + core.warning(`No dependency range report found at ${reportPath}`) |
| 64 | + return |
| 65 | + } |
| 66 | +
|
| 67 | + const report = JSON.parse(fs.readFileSync(reportPath, "utf8")) |
| 68 | + const dependencyFailures = [] |
| 69 | +
|
| 70 | + for (const packageResult of report.packages ?? []) { |
| 71 | + for (const dependency of packageResult.dependencies ?? []) { |
| 72 | + const candidateVersions = new Set(dependency.candidate_versions ?? []) |
| 73 | + const failedAttempts = (dependency.attempts ?? []).filter( |
| 74 | + (attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper) |
| 75 | + ) |
| 76 | + if (!failedAttempts.length) { |
| 77 | + continue |
| 78 | + } |
| 79 | +
|
| 80 | + const failuresByVersion = new Map() |
| 81 | + for (const attempt of failedAttempts) { |
| 82 | + const version = attempt.trial_upper || "unknown" |
| 83 | + if (!failuresByVersion.has(version)) { |
| 84 | + failuresByVersion.set(version, attempt.error || "No error output captured.") |
| 85 | + } |
| 86 | + } |
| 87 | +
|
| 88 | + dependencyFailures.push({ |
| 89 | + packageName: packageResult.package_name, |
| 90 | + projectPath: packageResult.project_path, |
| 91 | + dependencyName: dependency.name, |
| 92 | + originalRequirements: dependency.original_requirements ?? [], |
| 93 | + finalRequirements: dependency.final_requirements ?? [], |
| 94 | + failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })), |
| 95 | + }) |
| 96 | + } |
| 97 | + } |
| 98 | +
|
| 99 | + if (!dependencyFailures.length) { |
| 100 | + core.info("No failing dependency candidates found.") |
| 101 | + return |
| 102 | + } |
| 103 | +
|
| 104 | + const owner = context.repo.owner |
| 105 | + const repo = context.repo.repo |
| 106 | + const openIssues = await github.paginate(github.rest.issues.listForRepo, { |
| 107 | + owner, |
| 108 | + repo, |
| 109 | + state: "open", |
| 110 | + per_page: 100, |
| 111 | + }) |
| 112 | + const openIssueTitles = new Set( |
| 113 | + openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title) |
| 114 | + ) |
| 115 | +
|
| 116 | + const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''") |
| 117 | +
|
| 118 | + for (const failure of dependencyFailures) { |
| 119 | + const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})` |
| 120 | + if (openIssueTitles.has(title)) { |
| 121 | + core.info(`Issue already exists: ${title}`) |
| 122 | + continue |
| 123 | + } |
| 124 | +
|
| 125 | + const visibleFailures = failure.failedVersions.slice(0, 5) |
| 126 | + const omittedCount = failure.failedVersions.length - visibleFailures.length |
| 127 | + const failureDetails = visibleFailures |
| 128 | + .map( |
| 129 | + (entry) => |
| 130 | + `- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\`` |
| 131 | + ) |
| 132 | + .join("\n\n") |
| 133 | +
|
| 134 | + const body = [ |
| 135 | + "Automated dependency range validation found candidate versions that failed checks.", |
| 136 | + "", |
| 137 | + `- Package: \`${failure.packageName}\``, |
| 138 | + `- Project path: \`${failure.projectPath}\``, |
| 139 | + `- Dependency: \`${failure.dependencyName}\``, |
| 140 | + `- Original requirements: ${ |
| 141 | + failure.originalRequirements.length |
| 142 | + ? failure.originalRequirements.map((value) => `\`${value}\``).join(", ") |
| 143 | + : "_none_" |
| 144 | + }`, |
| 145 | + `- Final requirements after run: ${ |
| 146 | + failure.finalRequirements.length |
| 147 | + ? failure.finalRequirements.map((value) => `\`${value}\``).join(", ") |
| 148 | + : "_none_" |
| 149 | + }`, |
| 150 | + "", |
| 151 | + "### Failed versions and errors", |
| 152 | + failureDetails, |
| 153 | + omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "", |
| 154 | + "", |
| 155 | + `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`, |
| 156 | + ].join("\n") |
| 157 | +
|
| 158 | + await github.rest.issues.create({ |
| 159 | + owner, |
| 160 | + repo, |
| 161 | + title, |
| 162 | + body, |
| 163 | + }) |
| 164 | + openIssueTitles.add(title) |
| 165 | + core.info(`Created issue: ${title}`) |
| 166 | + } |
| 167 | +
|
| 168 | + - name: Refresh lockfile |
| 169 | + # Only refresh lockfile after a clean validation to avoid committing known-bad ranges. |
| 170 | + if: steps.validate_ranges.outcome == 'success' |
| 171 | + run: uv lock --upgrade |
| 172 | + working-directory: ./python |
| 173 | + |
| 174 | + - name: Commit and push dependency updates |
| 175 | + id: commit_updates |
| 176 | + if: steps.validate_ranges.outcome == 'success' |
| 177 | + run: | |
| 178 | + BRANCH="automation/python-dependency-range-updates" |
| 179 | +
|
| 180 | + git config user.name "github-actions[bot]" |
| 181 | + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" |
| 182 | + git checkout -B "${BRANCH}" |
| 183 | +
|
| 184 | + git add python/packages/*/pyproject.toml python/uv.lock |
| 185 | + if git diff --cached --quiet; then |
| 186 | + echo "has_changes=false" >> "$GITHUB_OUTPUT" |
| 187 | + echo "No dependency updates to commit." |
| 188 | + exit 0 |
| 189 | + fi |
| 190 | +
|
| 191 | + git commit -m "chore: update dependency ranges" |
| 192 | + git push --force-with-lease --set-upstream origin "${BRANCH}" |
| 193 | + echo "has_changes=true" >> "$GITHUB_OUTPUT" |
| 194 | +
|
| 195 | + - name: Create or update pull request with GitHub CLI |
| 196 | + # Only open/update PRs for validated updates to keep automation branches trustworthy. |
| 197 | + if: steps.validate_ranges.outcome == 'success' && steps.commit_updates.outputs.has_changes == 'true' |
| 198 | + run: | |
| 199 | + BRANCH="automation/python-dependency-range-updates" |
| 200 | + PR_TITLE="Python: chore: update dependency ranges" |
| 201 | + PR_BODY_FILE="$(mktemp)" |
| 202 | +
|
| 203 | + cat > "${PR_BODY_FILE}" <<'EOF' |
| 204 | + This PR was generated by the dependency range validation workflow. |
| 205 | +
|
| 206 | + - Ran `uv run poe validate-dependency-bounds-project --mode upper --project "*"` |
| 207 | + - Updated package dependency bounds |
| 208 | + - Refreshed `python/uv.lock` with `uv lock --upgrade` |
| 209 | + EOF |
| 210 | +
|
| 211 | + PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')" |
| 212 | + if [ -n "${PR_NUMBER}" ]; then |
| 213 | + gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" |
| 214 | + else |
| 215 | + gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" |
| 216 | + fi |
0 commit comments