dependency-update #2
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
| name: dependency-update | |
| on: | |
| schedule: | |
| - cron: '0 9 1 */3 *' # quarterly: 09:00 UTC, 1st of Jan/Apr/Jul/Oct | |
| - cron: '0 9 * * *' # daily 09:00 UTC safety check (no-op when lock is fresh) | |
| workflow_dispatch: {} | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write # peter-evans/create-pull-request auto-creates the labels we apply | |
| actions: write # required for `gh workflow run` re-trigger of ci.yml on the bot PR | |
| concurrency: | |
| group: dependency-update | |
| cancel-in-progress: false | |
| jobs: | |
| lock-upgrade: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # Actions in this workflow are SHA-pinned (not tag-pinned) because this job carries | |
| # write scope (contents + pull-requests + issues + actions). Tag retargeting on a | |
| # write-scope workflow yields code execution with those permissions — the supply-chain | |
| # blast radius is much larger than ci.yml's read-only context, so the cost of a | |
| # mutable tag is asymmetric. ci.yml stays on tag pins per architecture policy. | |
| steps: | |
| - id: route | |
| name: Route MODE by event (explicit allowlist; fail on unknown) | |
| run: | | |
| EVENT="${{ github.event_name }}" | |
| SCHEDULE="${{ github.event.schedule }}" | |
| if [ "$EVENT" = "workflow_dispatch" ]; then | |
| MODE=upgrade | |
| elif [ "$EVENT" = "schedule" ] && [ "$SCHEDULE" = "0 9 1 */3 *" ]; then | |
| MODE=upgrade | |
| elif [ "$EVENT" = "schedule" ] && [ "$SCHEDULE" = "0 9 * * *" ]; then | |
| MODE=safety-check | |
| else | |
| echo "::error::Unexpected event/schedule for dependency-update: event=${EVENT} schedule=${SCHEDULE}" | |
| exit 1 | |
| fi | |
| echo "mode=${MODE}" >> "$GITHUB_OUTPUT" | |
| echo "Routed MODE=${MODE} (event=${EVENT} schedule=${SCHEDULE})" | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: extractions/setup-just@dd310ad5a97d8e7b41793f8ef055398d51ad4de6 # v2 | |
| - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: "**/uv.lock" | |
| - run: uv python install 3.10 | |
| - name: Safety check (lock consistency) | |
| if: steps.route.outputs.mode == 'safety-check' | |
| run: | | |
| # uv lock --check reads pyproject.toml + uv.lock and verifies consistency | |
| # without requiring a synced venv; drift surfaces as non-zero exit. Wrapped | |
| # warning-only per AC8 so daily drift is observable but never fails the build — | |
| # the quarterly upgrade job is what reconciles it. | |
| if ! uv lock --check; then | |
| echo "::warning::uv lock --check detected drift between pyproject.toml and uv.lock; quarterly upgrade will reconcile." | |
| exit 0 | |
| fi | |
| - name: Upgrade lockfile | |
| if: steps.route.outputs.mode == 'upgrade' | |
| run: | | |
| uv lock --upgrade | |
| echo "## Lockfile diff" >> "$GITHUB_STEP_SUMMARY" | |
| git --no-pager diff --stat uv.lock >> "$GITHUB_STEP_SUMMARY" || true | |
| - name: Open PR with lockfile bump | |
| if: steps.route.outputs.mode == 'upgrade' | |
| id: create_pr | |
| uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| branch: chore/dependency-update-${{ github.run_id }} | |
| base: main | |
| # add-paths scopes staging to uv.lock so any sibling drift from `uv lock --upgrade` | |
| # (e.g. an embedded cache file, .python-version regeneration) does NOT silently | |
| # land in the PR. | |
| add-paths: uv.lock | |
| commit-message: "chore(deps): quarterly uv lock --upgrade" | |
| title: "chore(deps): quarterly dependency update (uv lock --upgrade)" | |
| body: | | |
| Automated quarterly dependency update per NFR26. | |
| ## Lockfile diff | |
| See the workflow run for the `git diff --stat uv.lock` summary: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| ## Review window | |
| Per NFR26, if no maintainer merges within 30 days, the build remains on the prior lock to avoid silent drift. | |
| labels: dependencies,automated | |
| signoff: false | |
| - name: Trigger ci.yml on the bot PR branch | |
| # PRs opened with the default GITHUB_TOKEN do NOT trigger workflow_run events, | |
| # so ci.yml will not run on the lockfile-bump PR without an explicit dispatch. | |
| # Without this step the quarterly PR lands with zero CI signal — exactly the | |
| # change that most needs pytest + pip-audit + LOC gate signal. | |
| if: steps.route.outputs.mode == 'upgrade' && steps.create_pr.outputs.pull-request-number != '' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh workflow run ci.yml --ref "chore/dependency-update-${{ github.run_id }}" | |
| - name: Capture PR number to step summary | |
| if: steps.route.outputs.mode == 'upgrade' && steps.create_pr.outputs.pull-request-number != '' | |
| run: | | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "pull-request-number: ${{ steps.create_pr.outputs.pull-request-number }}" >> "$GITHUB_STEP_SUMMARY" |