From 26469cb2125f4bc2f6992f6eabfa64c6e0fa6aee Mon Sep 17 00:00:00 2001 From: James Mineau Date: Tue, 20 Oct 2026 23:51:45 -0600 Subject: [PATCH] feat(ci): a monthly workflow runs uv lock --upgrade and opens a pull request Dependabot's uv updates never move a package whose newest release needs a newer Python than the lock's oldest (SciPy 1.18 and NumPy 2.5 need 3.12, the lock covers 3.11), so CI tested a release users on a newer Python no longer get. `uv lock --upgrade` locks the newest release for each Python (a fork in uv.lock). The Upgrade lock workflow runs on the 1st of each month and by hand, pushes chore/lock-upgrade and opens or updates one pull request when uv.lock changes. A pull request opened with the workflow's token does not start the other workflows, so it starts Tests, Code Quality (now also workflow_dispatch) and Documentation on the branch. The repository setting "Allow GitHub Actions to create and approve pull requests" is needed once; the after-copy message, README and CONTRIBUTING say so. --- README.md | 5 +- copier.yml | 5 +- .../.github/workflows/lock-upgrade.yml.jinja | 93 +++++++++++++++++++ template/.github/workflows/quality.yml.jinja | 1 + template/CONTRIBUTING.md.jinja | 10 ++ 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 template/.github/workflows/lock-upgrade.yml.jinja diff --git a/README.md b/README.md index ee4a5c3..760705f 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ first time is by hand; `copier update` takes over from then on. ``` GitHub Pages then rebuilds the site after every push to the branch. +7. On GitHub: Settings > Actions > General > Workflow permissions: tick "Allow + GitHub Actions to create and approve pull requests", so the monthly Upgrade + lock workflow can open its pull request. ## What you get @@ -108,7 +111,7 @@ first time is by hand; `copier update` takes over from then on. | pre-commit | pre-commit-hooks, rST checks, validate-pyproject, [zizmor](https://docs.zizmor.sh); `uv lock`, ruff and pyrefly run from `.venv`, so `uv.lock` pins their versions | | Changelog | Keep a Changelog, hand-edited; `just changelog` drafts entries from Conventional Commits ([git-cliff](https://git-cliff.org)) | | CI | Tests (the chosen operating systems × all supported Pythons), Code Quality, Documentation, Publish (tag push → PyPI trusted publishing with attestations → GitHub Release from CHANGELOG → Zenodo) | -| Upkeep | Dependabot for actions, pre-commit hooks and `uv.lock` (monthly, grouped, 7-day cooldown; never raises minimum versions) | +| Upkeep | Dependabot for actions, pre-commit hooks and `uv.lock` (monthly, grouped, 7-day cooldown; never raises minimum versions); a monthly workflow runs `uv lock --upgrade` and opens a pull request when the lock changes, so the newest release for each supported Python gets tested | | Project files | AGENTS.md, CONTRIBUTING.md, CHANGELOG.md, CITATION.cff, `.zenodo.json`, issue forms, PR template | Generated projects score clean on the Scientific Python diff --git a/copier.yml b/copier.yml index 355fe44..ef35980 100644 --- a/copier.yml +++ b/copier.yml @@ -23,7 +23,10 @@ _message_after_copy: | - Settings > Pages > Source: Deploy from a branch, branch gh-pages, folder / (root). The Documentation workflow creates the branch on its first run on main, so this comes after that run. - - Settings > Secrets and variables > Actions: add CODECOV_TOKEN.{% if publish_to_pypi %} + - Settings > Secrets and variables > Actions: add CODECOV_TOKEN. + - Settings > Actions > General > Workflow permissions: tick "Allow GitHub + Actions to create and approve pull requests", so the monthly Upgrade lock + workflow can open its pull request.{% if publish_to_pypi %} - On PyPI, add a trusted publisher: workflow publish.yml, environment pypi. Optionally, Settings > Environments > pypi > Required reviewers: you, so each upload waits for your approval.{% endif %} diff --git a/template/.github/workflows/lock-upgrade.yml.jinja b/template/.github/workflows/lock-upgrade.yml.jinja new file mode 100644 index 0000000..c9b288f --- /dev/null +++ b/template/.github/workflows/lock-upgrade.yml.jinja @@ -0,0 +1,93 @@ +name: Upgrade lock + +# Once a month, re-lock every dependency to its newest release and open one pull +# request when uv.lock changes (`uv lock --upgrade`). Dependabot's uv updates do +# not do this on their own: a package whose newest release needs a newer Python +# than the oldest one this project supports stays at its old version, so CI would +# test a release that users on a newer Python no longer get. `uv lock --upgrade` +# locks the newest release for each Python (a fork in uv.lock). +# +# Setup, once: Settings > Actions > General > Workflow permissions > tick "Allow +# GitHub Actions to create and approve pull requests". +# +# A pull request opened with the workflow's own token does not start the other +# workflows, so this one starts Tests, Code Quality and Documentation on the +# branch itself (workflow_dispatch is the one event the token may trigger); their +# results show on the pull request. +on: + schedule: + - cron: "0 6 1 * *" # 06:00 UTC on the 1st of each month + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: lock-upgrade + cancel-in-progress: false + +env: + BRANCH: chore/lock-upgrade + +jobs: + upgrade: + runs-on: ubuntu-latest + permissions: + contents: write # push the branch + pull-requests: write # open or update the pull request + actions: write # start the other workflows on the branch + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 0 # setuptools-scm reads the version from tags + + - uses: astral-sh/setup-uv@v10.2.0 + with: + version: "{{ uv_version }}" + python-version: "{{ python_max }}" + + - name: Upgrade uv.lock + id: upgrade + run: | + set -euo pipefail + uv lock --upgrade 2>&1 | tee "$RUNNER_TEMP/lock.log" + if git diff --quiet -- uv.lock; then + echo "uv.lock is already up to date." + exit 0 + fi + { + echo "Monthly \`uv lock --upgrade\`: the newest release of every dependency, for each supported Python." + echo + echo '```' + grep -E '^(Added|Removed|Updated) ' "$RUNNER_TEMP/lock.log" || true + echo '```' + echo + echo "Tests, Code Quality and Documentation were started on this branch by the workflow run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo "Merge it when they pass." + } > "$RUNNER_TEMP/body.md" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Push the branch, open or update the pull request, start the checks + if: {% raw %}steps.upgrade.outputs.changed == 'true'{% endraw %} + env: + GH_TOKEN: {% raw %}${{ github.token }}{% endraw %} + run: | + set -euo pipefail + gh auth setup-git + git checkout --quiet -b "$BRANCH" + git add uv.lock + git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit --quiet -m "chore(deps): upgrade uv.lock" + git push --quiet --force origin "$BRANCH" # the branch belongs to this workflow + number="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')" + if [ -n "$number" ]; then + gh pr edit "$number" --body-file "$RUNNER_TEMP/body.md" + else + gh pr create --base main --head "$BRANCH" --title "chore(deps): upgrade uv.lock" \ + --body-file "$RUNNER_TEMP/body.md" + fi + for workflow in tests.yml quality.yml docs.yml; do + gh workflow run "$workflow" --ref "$BRANCH" + done diff --git a/template/.github/workflows/quality.yml.jinja b/template/.github/workflows/quality.yml.jinja index 1d2da37..9b6c61f 100644 --- a/template/.github/workflows/quality.yml.jinja +++ b/template/.github/workflows/quality.yml.jinja @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: permissions: contents: read diff --git a/template/CONTRIBUTING.md.jinja b/template/CONTRIBUTING.md.jinja index 1d8e8aa..7856c5f 100644 --- a/template/CONTRIBUTING.md.jinja +++ b/template/CONTRIBUTING.md.jinja @@ -78,6 +78,16 @@ Dependabot opens one pull request a month per kind of pin: GitHub Actions, pre-commit hooks, and `uv.lock` (the dev tools; it never raises the minimum versions in `pyproject.toml`). Merge it when CI passes. +Dependabot leaves a package at its old version when the newest release needs a +newer Python than the oldest one this project supports. So on the 1st of each +month the Upgrade lock workflow runs `uv lock --upgrade` (`uv.lock` then holds +the newest release for each Python) and, when the lock changes, opens the pull +request `chore/lock-upgrade` (or updates it). GitHub does not start workflows on +a pull request that its own token opened, so the workflow starts Tests, Code +Quality and Documentation on the branch itself; their results show on the pull +request. Merge it when they pass. You can also run it by hand: Actions > Upgrade +lock > Run workflow. + ## Template The tooling (CI workflows, pre-commit, justfile, packaging configuration) comes