Raises (or refreshes) an upgrade pull request in one repository that depends on a Python package: it moves the version specifier, relocks, and opens or updates the PR.
This is the second half of a publisher-side dependency fan-out. Pair it with
python-discover-dependents,
which finds the repositories worth bumping — so a release reaches its consumers without anyone
opening five PRs by hand.
It is deliberately mechanical: it moves the version specifier and relocks. A release that changes the API still needs a human to migrate the call sites. The PR is the starting point for that work, not a substitute for it.
- Dependabot and Renovate are consumer-side pollers, and both currently struggle with
uv.lockbehind a private index — Dependabot doesn't match index URLs carrying userinfo (as AWS CodeArtifact's do), and Renovate can silently fall back to public PyPI when refreshing auv.lock. They are also per-consumer configuration rather than a single publisher-side fan-out. peter-evans/create-pull-requestresets the PR branch onto its base and force-pushes. This action does the opposite on purpose: it appends to the bump branch, so migration commits a human pushed onto the PR survive the next release. That difference is the reason the branch handling here is not delegated.
If none of that applies to you — a public package on PyPI, no hand-written migration commits on the bump branch — reach for Dependabot first. It is less machinery.
Two steps, because the fan-out is a job matrix and only the caller can declare one.
name: Bump dependents
on:
release:
types: [released]
workflow_dispatch:
permissions:
contents: read
# Never cancel: two runs racing on the same branch would have the second push rejected as
# non-fast-forward, and a run cancelled mid-fan-out leaves the consumers it had not reached on
# the previous version. Queueing means each release appends its commit in order.
concurrency:
group: bump-dependents
cancel-in-progress: false
jobs:
discover:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.discover.outputs.version }}
repositories: ${{ steps.discover.outputs.repositories }}
has-dependents: ${{ steps.discover.outputs.has-dependents }}
steps:
- uses: CVector-Energy/python-discover-dependents@v1
id: discover
with:
package: my-package
app-id: ${{ vars.APP_ID }}
app-private-key: ${{ secrets.APP_PRIVATE_KEY }}
bump:
needs: discover
if: needs.discover.outputs.has-dependents == 'true'
runs-on: ubuntu-24.04
strategy:
fail-fast: false # one broken consumer must not stop the others getting their PR
max-parallel: 4
matrix:
repository: ${{ fromJSON(needs.discover.outputs.repositories) }}
steps:
- uses: CVector-Energy/python-bump-dependents@v1
with:
package: my-package
repository: ${{ matrix.repository }}
version: ${{ needs.discover.outputs.version }}
app-id: ${{ vars.APP_ID }}
app-private-key: ${{ secrets.APP_PRIVATE_KEY }}
reviewer: ${{ github.event.release.author.login || github.actor }}Publishing usually runs off the same release event, so discovery can start before the wheel
is on the index. wait-command is polled until it exits zero; it runs with PACKAGE and
VERSION in the environment. pre-lock-command runs just before uv lock and can export
credentials to $GITHUB_ENV.
Configure cloud credentials in the caller — a composite action cannot host another action's steps mid-sequence.
discover:
permissions:
contents: read
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::000000000000:role/MyPackage-Bump
aws-region: us-east-1
- uses: CVector-Energy/python-discover-dependents@v1
id: discover
with:
package: my-package
app-id: ${{ vars.APP_ID }}
app-private-key: ${{ secrets.APP_PRIVATE_KEY }}
wait-command: |
aws codeartifact describe-package-version \
--domain my-domain --domain-owner 000000000000 \
--repository internal --format pypi \
--package "$PACKAGE" --package-version "$VERSION" >/dev/null 2>&1 - uses: CVector-Energy/python-bump-dependents@v1
with:
# ...
pre-lock-command: |
TOKEN=$(aws codeartifact get-authorization-token \
--domain my-domain --domain-owner 000000000000 \
--region us-east-1 --query authorizationToken --output text)
echo "::add-mask::${TOKEN}"
echo "UV_INDEX_MY_INDEX_USERNAME=aws" >> "$GITHUB_ENV"
echo "UV_INDEX_MY_INDEX_PASSWORD=${TOKEN}" >> "$GITHUB_ENV"Give the bump role read-only index access, not publish. The relock resolves the dependents' full dependency trees, and uv builds any sdist whose metadata it cannot read statically — which runs third-party code in the same job that holds the registry token.
| Input | Required | Default | Description |
|---|---|---|---|
package |
yes | Package name as it appears in the dependent's pyproject.toml. |
|
repository |
yes | Bare name of the dependent to bump. | |
version |
yes | Version to roll out, no leading v. |
|
app-id |
yes | Client ID of a GitHub App installed on the dependent. | |
app-private-key |
yes | Private key of that app. | |
owner |
no | calling repo's owner | Org that owns the dependent. |
branch |
no | deps/<package> |
Branch to raise the PR from. |
committer-name |
no | github-actions[bot] |
Commit author name. |
committer-email |
no | the bot's noreply | Commit author email. |
reviewer |
no | Login to request review from; best-effort. | |
labels |
no | Comma-separated labels for the PR. | |
pre-lock-command |
no | Shell snippet run before uv lock. |
|
setup-uv |
no | true |
Install uv. |
| Output | Description |
|---|---|
relevant |
"true" if the repository actually resolves the package in a uv.lock. |
pushed |
"true" if a commit was pushed to the bump branch. |
pull-request-number |
Number of the PR opened or updated, empty if none. |
A repository that does not really depend on the package is skipped. Discovery matches
text, so a pyproject.toml naming the package in a comment can land in the matrix. This action
checks uv.lock for a resolved name = "<package>" entry first and reports relevant: false
if it isn't there — the resolver's own answer, rather than the same text the search matched.
Unconstrained dependencies are left alone. A bare "my-package" already resolves to the
newest release; pinning it would be a policy change nobody asked for. The relock still runs
for those repositories, so their lockfile moves even though the manifest doesn't.
Names are matched per PEP 503. A consumer depending on my_package is bumped by a
release of my-package.
The bump branch does not follow its base. A later release appends another commit rather than rebasing, so unmerged work on the PR is kept. Rebase or merge it as you would any other long-lived PR.
Relock runs twice. A monorepo keeps one lock per project, and a lock records the requirements of its path dependencies, so raising a shared library's floor leaves dependent locks stale regardless of the order the first pass ran in. Two passes settle that without modelling the dependency graph.
A partial relock failure still raises the PR, with a warning listing the projects that need relocking by hand. Every project failing stops the run instead.
uv run --group dev pytest
shellcheck scripts/*.shThe logic that would be painful to debug from a failed fan-out lives in scripts/ and is
tested directly: rewrite_specifier.py (which specifiers move and which don't) and
relock.sh (the failure policy, driven through a stub uv).
python-discover-dependents— the other half: finds the repositories in your org that depend on the package and resolves the version to roll out. Itsrepositoriesoutput is the matrix this action runs over.
The rest of the Python release pipeline these actions are built for:
publish-to-codeartifact— builds and publishes the wheel to AWS CodeArtifact. Its direct counterpart: that action puts a release on the index, this one rolls it out to the repositories that depend on it.pyproject-license-check— audits a project's resolved dependencies for license compliance.python-test— runs ruff, mypy and pytest for a Python repository. Worth having on the consumers, since it is their own CI that answers whether an upgrade PR from here is safe to merge.aws-ga-deployment-role— Terraform module for the GitHub OIDC role thewait-commandandpre-lock-commandexamples above assume.
Built on:
actions/create-github-app-token— mints the org-read token for discovery and the per-repository write tokens for the bumps.astral-sh/setup-uvand uv — the resolver behind the relock.
Alternatives worth considering before this one — see Why not Dependabot, Renovate, or create-pull-request? for when each is the better fit:
- Dependabot and Renovate — consumer-side pollers.
peter-evans/create-pull-request— the general-purpose way to raise a PR from a workflow, if you do not need the append-only branch behaviour.
MIT