Three reusable workflows keep a repository's dependencies current without giving project code a write token. Each one splits the work in two: a read-only job produces a patch, and a separate writer job, the only one that sees the App key, checks the patch and opens or updates one pull request. The pull request then runs the normal checks before anyone merges it.
| Workflow | Use it when |
|---|---|
upgrade-release-tools.yaml |
You want a weekly refresh of pylock.toml (and a secondary build lock) under the cutoff. |
update-python-dependencies.yaml |
You also want dependency floors in pyproject.toml, tox.toml and similar bumped. |
open-maintenance-pr.yaml |
Your own read-only job generates some other change, such as regenerated diagrams. |
All three need the same setup:
- A repository-only release App with Contents and Pull requests write access, installed on this repository only.
- The
release-authenvironment, restricted tomain, holding theRELEASE_APP_PRIVATE_KEYsecret and theRELEASE_APP_CLIENT_IDvariable (a repository variable works too). - The caller passes the secret by name. GitHub requires this mapping even when the key exists only in the writer job's environment.
- Trigger the caller on
push,schedule,workflow_dispatch, orrelease. The writer job refusespull_request,pull_request_target,workflow_run,issue_comment, andmerge_groupcallers: its first step fails with an error naming the event, before the checkout and before the App token exists.
The read-only job first runs check-nab-exceptions, so every scheduled run
fails on an expired or undated cutoff exception even when no release input changed. It then runs
refresh-release-lock in refresh mode, which rebuilds the universal
pylock.toml with nab lock --all-groups --all-extras --no-cache --upgrade: runtime dependencies, all extras, and all
dependency groups. The project's [tool.nab] table sets the cutoff and any per-package exception; the caller adds and
removes those exceptions itself. uv only installs tools and never resolves the lock.
When the lock changes, the job uploads a patch that may change only pylock.toml and the configured secondary lock.
open-maintenance-pr checks it before minting the App token
and opens or updates one pull request from automation/update-release-tools to main, labelled dependencies and
skip news. Create both labels and allow the App to push that branch.
name: Upgrade release tools
on:
workflow_dispatch:
schedule:
- cron: 0 8 * * 2
permissions: {}
jobs:
upgrade:
uses: >-
tox-dev/github-workflows/.github/workflows/upgrade-release-tools.yaml@SHA
permissions:
contents: read
secrets:
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}| Input | Default | Purpose |
|---|---|---|
pull-request-title |
🔧 build(deps): refresh release tools |
Pull request title, also the commit subject. |
pull-request-body |
Refresh the project lock. |
Pull request body. |
additional-lock |
empty | Basename of a secondary Nab lock, such as pylock.build.toml, refreshed in the same PR. |
additional-lock-groups |
empty | Dependency groups to select in the secondary lock; empty selects build requirements. |
additional-lock-selection |
build-or-groups |
Set project for a secondary lock that selects the project's runtime dependencies instead. |
additional-matrix-python |
empty | Python range for the secondary lock. |
additional-matrix-platforms |
empty | Space-separated Nab platform IDs for the secondary lock. |
additional-matrix-implementations |
empty | Space-separated implementations for the secondary lock. |
exclude-newer |
7 days |
Cutoff for the tools the Actions install from PyPI, see cutoffs. |
exclude-newer-package |
empty | Newline-separated name=date overrides of that cutoff. |
Secret: RELEASE_APP_PRIVATE_KEY (required). No outputs.
Pitfalls:
pylock.tomlmust come fromnab lock --all-groups --all-extras. Relock arelease-creator lockslock with Nab before re-pinning.- Tests install test, type, and documentation tools afresh to catch upstream dependency changes; only release jobs use the locked groups.
- The workflow never updates a committed
uv.lock. Build constraints come from the lock'sbuildgroup at runtime. - With a secondary lock, pass the same
additional-lockand matrix inputs tovalidate-newsin the PR workflow. It then checks changes to either lock against the project, including a PR that changes only the secondary lock.
The workflow bumps the direct requirements in the files you name with bump-deps-index, regenerates pylock.toml, runs
the repository's hooks, and opens a dependency pull request. Choose kind by what you ship:
kind: app(default): every bumped floor waits out the cooldown, and so does every locked release.kind: library: the groups infresh-groupstake their newest release at once, passing--exclude-newer-group GROUP=falsefor each, so tests run against what users install. Every other group, the build backend, and the hooks stay cooled.
name: Update Python dependencies
on:
workflow_dispatch:
inputs:
cooldown:
description: Upload age a bumped release needs, such as 7 days; 0 days turns the cooldown off
type: string
default: 7 days
expedite:
description: Space-separated packages whose newest release skips the cooldown for this run
type: string
default: ""
schedule:
- cron: 0 8 * * 2
permissions: {}
jobs:
update:
permissions:
contents: read
uses: tox-dev/github-workflows/.github/workflows/update-python-dependencies.yaml@SHA
with:
files: |
pyproject.toml tox.toml
cooldown: ${{ inputs.cooldown || '7 days' }}
expedite: ${{ inputs.expedite || '' }}
secrets:
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}| Input | Default | Purpose |
|---|---|---|
files (required) |
Newline-separated groups. Each line is one bump-deps-index --file run over its space-separated paths, so a file that needs a different cutoff belongs on its own line. Paths must be plain relative paths, without ... |
|
cooldown |
7 days |
Upload age a bumped release needs, N days only. 0 days turns the bump cooldown off for the run. |
expedite |
empty | Whitespace-separated package names that take their newest release for this run, past the cooldown. It changes only the bump: a locked package also needs a committed [tool.nab.packages.NAME] exception, which later runs keep. |
kind |
app |
app or library, see above. Any other value fails. |
fresh-groups |
project test |
Groups kind: library keeps fresh. project means the [project] runtime dependencies and extras. Names are validated; ignored for app; library needs at least one. |
exclude-newer |
7 days |
Cutoff for installing bump-deps-index and the locked tools. The tool is tooling, so the default keeps it cooled. |
exclude-newer-package |
empty | Newline-separated name=date overrides; a dated entry admits a tool release younger than the cutoff. |
allowed-paths |
empty | Newline-separated globs for files a repository hook rewrites, such as a generated README. pylock.toml and every path in files are always allowed. |
branch |
update-python-dependencies |
Passed to open-maintenance-pr. |
title |
Update Python dependencies |
Pull request title and commit subject. |
body |
Update dependency floors and the lock from the scheduled scan. |
Pull request body. |
labels |
empty | Newline-separated labels; each must exist. |
Secret: RELEASE_APP_PRIVATE_KEY (required). No named output. Remote effect: one dependency pull request into main
through open-maintenance-pr, or none when nothing changed.
How the read-only job runs:
- Installs the caller's locked
release-preparegroup, so the caller'spylock.tomlneeds that group, andbump-deps-index(a release that supports--exclude-newer-groupand--exclude-newer-package) underexclude-newer. - Runs
bump-deps-indexonce perfilesline. - Runs
refresh-release-lockto upgrade the lock with Nab. A lock whose only change is Nab'screated-atstamp is restored, so a timestamp alone never opens a pull request. - Runs
cooled-prekrun --all-filesonce. Hooks may rewrite files; a hook failure that changes nothing fails the job, and the pull request's checks verify the rewritten result. Put a repository's own formatting, README generation, and other steps in its prek hooks.
Use this for any generated change your own read-only job produces. Run the repository's hooks and generation in that job, before the artifact exists; the reusable workflow never runs project code.
The caller writes the change as a binary Git patch named maintenance.patch, uploads it as an artifact, and passes the
artifact ID. The artifact name is free, but give it one unique within the run: two maintenance workflows in one run that
share a name make the download fail with None of the provided artifact IDs were found.
The workflow runs two jobs:
validateapplies the patch to a scratch index, leaving the checkout unchanged, and checks that every changed path matchesallowed-pathsand that each change adds, modifies or deletes a regular non-executable file. A patch that changes nothing fails. It records the patch digest.openis the only job that mints the App token, with Contents and Pull requests write for this repository only. It runs in therelease-authenvironment, verifies the digestvalidaterecorded, commits asgithub-actions[bot], and force-pushesbranchwith a lease. It then edits the open pull request frombranchintobase, addinglabels, or opens one. Runs for the same branch are serialized and never cancelled.
jobs:
generate:
runs-on: ubuntu-26.04
permissions:
contents: read
outputs:
artifact-id: ${{ steps.upload.outputs.artifact-id }}
steps:
# ... produce the changes in the working tree, then write $RUNNER_TEMP/maintenance.patch
- id: upload
uses: actions/upload-artifact@UPLOAD_SHA
with:
name: maintenance-patch-diagrams
path: ${{ runner.temp }}/maintenance.patch
if-no-files-found: error
open:
needs: generate
permissions:
contents: read
uses: tox-dev/github-workflows/.github/workflows/open-maintenance-pr.yaml@SHA
with:
artifact-id: ${{ needs.generate.outputs.artifact-id }}
allowed-paths: |
docs/*.md
site/static/diagrams/*.svg
branch: automation/update-diagrams
title: Update diagrams
body: Regenerate the diagrams from the scheduled scan.
labels: skip news
secrets:
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}| Input | Default | Purpose |
|---|---|---|
artifact-id (required) |
ID of the patch artifact from the same workflow run. | |
allowed-paths (required) |
Newline-separated shell glob patterns: an exact path or a glob. Dots are literal, and * also matches /. |
|
branch (required) |
Branch holding the maintenance commit. A later run force-pushes it with a lease, replacing the earlier commit. | |
base |
main |
Branch the pull request targets. |
title (required) |
Pull request title, also the commit subject. | |
body |
empty | Pull request body. |
labels |
empty | Newline-separated labels to add. Each label must already exist in the repository; an existing PR keeps its old labels. |
Secret: RELEASE_APP_PRIVATE_KEY (required). The caller grants its job contents: read.
Remote effect: one force-pushed commit on branch and one pull request into base. An open pull request on the same
branch is updated; two open pull requests on one branch fail. The open job records the pull request number, but the
workflow declares no workflow_call output, so the caller cannot read it.