Skip to content

dependency-update

dependency-update #2

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"