Your repo's CI deputy — PR checks, release tagging, and gitops image bumps as
testable Python, not logic buried in Actions YAML. Workflows pip install
deputy and call deputy <command>; the logic runs and is tested locally
instead of by pushing commits and reading Actions logs.
pip install "deputy @ git+https://github.com/Krande/deputy.git@v0.8.1"In a workflow, prefer the action over the three steps it replaces — the ref is the pin:
- uses: Krande/deputy@v0.8.1
with:
command: pr-review
github-token: ${{ secrets.GITHUB_TOKEN }}
has-source-key: ${{ secrets.SOURCE_KEY != '' }}- uses: Krande/deputy@v0.8.1
with:
command: tag-on-merge
github-token: ${{ secrets.GITHUB_TOKEN }}Flag-taking commands pass them through args:
- uses: Krande/deputy@v0.8.1
with:
command: release-watch
args: --all
github-token: ${{ secrets.GITHUB_TOKEN }}Why this and not pip install in a run: step:
- One version per call site, where tooling can see it. A version inside a
run:string is invisible to Dependabot'sgithub-actionsecosystem, which readsuses:. It is also easy to bump in one workflow and miss another — adacpp ran two different deputies from one commit that way. - No package index. The action installs from its own checkout, so this needs neither PyPI nor conda-forge.
github-tokenis set as bothGITHUB_TOKENandGH_TOKEN, becausepr-reviewreads the first andtag-on-mergethe second; callers should not have to know that.
The action does not check out your repository — add actions/checkout before
it, with whatever ref/fetch-depth the command needs (tag-on-merge needs
full history and the SSH deploy key).
To call deputy from any directory, install it globally with pixi — deputy ships
a pixi-build manifest, so one command builds and
installs it (its runtime deps come from conda-forge):
pixi global install deputy --git https://github.com/Krande/deputy.git --tag v0.8.1pixi builds deputy from source under its cache dir during that install. If you'd
rather the build (and cache) live somewhere specific, point PIXI_CACHE_DIR there
first — pixi puts its build workspace under it:
export PIXI_CACHE_DIR=/path/to/pixi-cache # PowerShell: $env:PIXI_CACHE_DIR = "D:\pixi-cache"
# (RATTLER_CACHE_DIR takes the same value if you want the resolver cache there too)Prefer a prebuilt install with no from-source build? Install it as a uv tool instead — uv itself installs via pixi:
pixi global install uv
uv tool install "deputy @ git+https://github.com/Krande/deputy.git@v0.8.1"
uv tool update-shell # one-time: add uv's tool dir to PATH, then reopen the shell| Command | What it does |
|---|---|
deputy pr-review |
Check a PR (conventional title + exactly one release-* label), compute the next version for information, post/update one sticky comment, set the review_ok output, exit non-zero if a check fails. |
deputy tag-on-merge |
On a merged PR, run python-semantic-release per the release label to bump the version, tag vX.Y.Z, push it, and cut a GitHub Release. |
deputy gitops-update |
Bump a container image reference in a gitops YAML file (comment-preserving, matching a k8s kind) and commit + push. |
deputy release-watch |
On a schedule, check watched upstream repos for a newer release/tag than the version pinned in this repo, and open (or update) a PR bumping the pin. |
deputy sshkey |
Generate a passphrase-less ed25519 SSH key (e.g. a CI deploy key), remembering the email so it auto-fills next time, into a unique filename. --print also dumps the private key for pasting into a secret. |
Create a deploy key without memorising ssh-keygen flags. The email is
remembered (in ~/.config/deputy/sshkey.json) and reused when you omit --email,
and the filename is made unique so repeat runs never clobber an earlier key.
deputy sshkey --email dev@example.com # writes ~/.ssh/deputy_ed25519_dev_at_example_com[.pub]
deputy sshkey # reuses the remembered email
deputy sshkey --out ./keys --print # custom dir; also prints the private key to paste into a CI secretThe public key is printed to register as a repo Deploy key; add --print to
also print the private key (the value for the consumer's Actions secret).
Declarative (targets from deputy.toml, only the tag changes per run):
deputy gitops-update --target web-app-beta --tag sha-abc-42 # one target
deputy gitops-update --all --tag sha-abc-42 # every targetAd-hoc (everything on the command line, no deputy.toml):
deputy gitops-update \
--repo-dir gitops --file cluster/app-deployment.yaml \
--kind Deployment --image-path spec.template.spec.containers.0.image \
--image ghcr.io/owner/app:1.2.3Patches only the image line (ruamel round-trip preserves comments, key order,
and indentation), for every document whose top-level kind matches. Fails loud
if the kind/path don't match rather than pushing an unchanged file. The gitops
repo is expected to be already checked out with push auth in place (the
workflow's checkout step handles the deploy key), the same way tag-on-merge
relies on the checkout's SOURCE_KEY.
Keep a dependency pinned to an upstream repo's latest release, automatically. On
a schedule, deputy checks each watched target's upstream for a newer
release/tag than the version currently pinned in your repo, and opens a PR that
bumps the pin. Targets live in deputy.toml (see below).
deputy release-watch --all # check every [[release_watch]] target
deputy release-watch --target some-lib # just one (repeatable)
deputy release-watch --all --dry-run # compute + print, open nothing
deputy release-watch --all --base develop # PRs target a non-default base branchPer target deputy:
- Looks up the upstream repo's latest GitHub Release, falling back to its highest semver tag when no release is published.
- Reads the currently-pinned version out of your file via a regex with one capture group (group 1 is the version).
- If upstream is strictly newer (semver compare, leading
vand pre-releases handled), rewrites only the captured span, commits it to a per-target branch (<branch_prefix>/<name>), pushes, and opens a PR.
It is idempotent: the PR is keyed to the per-target head branch, so a later run that finds a still-newer release updates the same PR instead of opening a duplicate. Up-to-date targets are no-ops. A pattern that matches nothing fails loud (non-zero exit) rather than silently doing nothing.
One pin in several files. Where the same pattern has to be bumped across
more than one file, give the target a files list instead of file: all of
them are rewritten, committed and opened as one PR, so pins that must move
in lockstep cannot end up split across PRs that merge at different times. If
the files have drifted apart, the comparison uses the oldest pin, so a file
left behind is still caught up. A file the pattern misses aborts the target
before anything is written — no half-done bump.
[[release_watch]]
name = "some-lib"
repo = "owner/some-lib"
files = ["deploy/app.yaml", "deploy/worker.yaml"] # bumped together, one PR
pattern = 'some-lib:([0-9]+\.[0-9]+\.[0-9]+)'Pins that are meant to move independently stay separate [[release_watch]]
targets, each with its own file, branch and PR — watching one upstream from
two targets is fine and is how two environments are kept on their own release
cadence.
Container images. For Kubernetes manifests, name the containers instead of
writing a pattern. deputy then sets each selected container's whole image
to <image>:<release> — registry, repository and tag — rather than splicing a
version into whatever was there:
[[release_watch]]
name = "app"
repo = "owner/app"
image = "ghcr.io/owner/app" # the release image, without a tag
files = ["deploy/app.yaml", "deploy/worker.yaml"]
containers = ["migrate", "api", "worker"] # container / initContainer namesThat matters when a manifest does not always run a release. If a development
pipeline points a container at registry.example.com/app:sha-abc1234 in the
meantime, a pattern written for the release registry no longer matches; an image
target still finds the container by name. While every selected container runs a
release of image, versions are compared as usual. When any runs something else
there is no version to compare, so the PR puts it back on the latest release —
set on_other_image = "skip" to leave such containers alone until someone moves
them back. Containers you do not list (a wait-for-db init helper on busybox)
are never touched, and a listed name that matches nothing fails the target before
anything is written.
Auth is the GH_TOKEN env var; GITHUB_REPOSITORY (owner/repo, provided by
Actions) names the repo the PRs are opened on. --dry-run needs neither a repo
nor push access.
| Env | Default | Meaning |
|---|---|---|
GH_TOKEN |
— (required) | Token for the repo the PRs are opened on. |
DEPUTY_API_URL |
$GITHUB_API_URL, else https://api.github.com |
REST base of the forge hosting that repo. |
DEPUTY_UPSTREAM_API_URL |
the PR client | REST base to look upstream releases up on, when that is another forge. |
DEPUTY_UPSTREAM_TOKEN |
anonymous | Token for DEPUTY_UPSTREAM_API_URL (public read is enough). |
The PR side only uses the Gitea-compatible subset of the API, so the consumer
repo can live on Forgejo while the watched upstreams stay on GitHub. Forgejo
Actions sets GITHUB_API_URL to the instance's /api/v1, so only the upstream
needs naming:
- run: deputy release-watch --all
env:
GH_TOKEN: ${{ secrets.BOT_TOKEN }} # a bot account's token, so its PRs notify you
GITHUB_REPOSITORY: ${{ github.repository }}
DEPUTY_UPSTREAM_API_URL: https://api.github.com
DEPUTY_UPSTREAM_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }}Two Forgejo differences worth knowing: it ignores the head filter when listing
PRs (deputy matches the head branch itself), and adding a label the repo does
not have is not an auto-create as on GitHub — create the labels up front, or
set labels = [].
A repo wires it up with a cron workflow that checks out, installs deputy, and
calls release-watch. Generic example:
# .github/workflows/release-watch.yml
name: release-watch
on:
schedule:
- cron: "0 6 * * 1" # every Monday 06:00 UTC
workflow_dispatch: {} # allow manual runs
permissions:
contents: write # push the bump branch
pull-requests: write # open/update the PR
jobs:
watch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install "deputy @ git+https://github.com/Krande/deputy.git@v0.8.1"
- run: deputy release-watch --all
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}Keep the static flags in a deputy.toml so workflows pass only what changes per
run. Precedence is CLI flag / env var → deputy.toml → built-in default, so
flags always win. Found at ./deputy.toml (override with --config /
$DEPUTY_TOML); everything works with no file at all, on defaults.
[images] # optional: name -> ref, keeps targets DRY
app = "registry.example.com/web-app"
[[gitops]] # repeatable rollout targets
name = "web-app-beta"
image = "app" # an [images] key, or a full ref inline
repo_dir = "gitops"
file = "clusters/prod/web-app.yaml"
kind = "Deployment"
image_path = "spec.template.spec.containers.0.image"
message = "chore({name}): deploy {tag}" # {name}/{tag}/{image}/{kind} templated
[[release_watch]] # repeatable dependency-watch targets
name = "some-lib" # label used in branch/commit/PR text
repo = "owner/some-lib" # upstream repo to query for the latest release
file = "requirements.txt" # file in THIS repo holding the pin
# (or files = [...] to bump several in one PR)
pattern = 'some-lib==([0-9]+\.[0-9]+\.[0-9]+)' # regex; group 1 = the version to bump
# optional, with sensible defaults:
pr_title = "chore: bump {name} to {version}" # {name}/{version} templated
branch_prefix = "deputy/release-watch" # head branch is "<branch_prefix>/<name>"
labels = ["dependencies"] # labels applied to the PR
[pr_review]
marker = "<!-- MY_PR_BOT -->" # keep an existing sticky-comment thread
default_label = "release-skip" # label applied when a PR carries no release-*
# (default: "release-auto")
[release] # semantic-release overrides (see below)
version_toml = ["pyproject.toml:project.version"]
version_json = ["src/frontend/package-lock.json"] # npm-aware bump; deputy's own key| Field | Required | Default | Meaning |
|---|---|---|---|
marker |
no | <!-- DEPUTY_PR_BOT --> |
Sticky-comment marker; set it to a previous bot's marker to keep an existing thread. |
default_label |
no | release-auto |
The release-* label pr-review applies when a PR carries none. The built-in default releases at whatever the commit history implies; set it to release-skip to make releasing opt-in instead. One of release-skip / release-auto / release-patch / release-minor / release-major — anything else is a hard error rather than a silent fallback. |
default_label is honoured by both flows: pr-review applies it to the PR,
and tag-on-merge falls back to it when the PR carries no release-* label (so
a PR that never went through pr-review, or had its label removed, still gets
the repo's intended behaviour). An explicit label always wins over the default —
in particular a PR labelled release-skip never releases, whatever the default is.
The default is a stand-in, so deputy takes it back off the PR as soon as a
real choice appears next to it. Add release-patch to a PR deputy has already
defaulted to release-auto, and the next pr-review run removes release-auto
and says so in its comment; the PR is left carrying exactly one label.
This matters because two release-* labels release nothing at all. Without
the rule, the most ordinary sequences leave two behind and the PR merges green
with no version cut and nothing to notice:
- deputy defaults a label-less PR, then the author adds the label they wanted;
- someone removes a label and re-adds it to re-trigger the check — the removal
re-runs
pr-reviewagainst a PR with norelease-*label, so the default goes on, and re-adding the real label makes two.
tag-on-merge applies the same rule, so a PR that merged still carrying both
releases at the explicit label's level rather than silently doing nothing.
Deliberately not covered: two explicit labels (release-patch +
release-minor) remain a hard error. Guessing between two stated choices would
ship the wrong version, and the only label deputy will ever remove from a PR is
the default it applies itself. One consequence worth knowing: a label set carries
no author, so a PR labelled with the default by hand looks identical to one
deputy defaulted, and the rule drops it too.
Your pr-review workflow should therefore trigger on labeled as well as
unlabeled:
on:
pull_request_target:
types: [opened, synchronize, edited, labeled, unlabeled]Without labeled, adding a release label never re-runs the review, so the green
tick you merge on was calculated against a label set that no longer exists.
deputy's own label edits do not re-trigger the workflow (events raised with
GITHUB_TOKEN do not start workflow runs), and a repeated run is a no-op anyway.
Note the key lives under [pr_review], not [release]: [release] is
deep-merged into the semantic-release config deputy generates, so a deputy-only
key there would be handed to semantic-release.
| Field | Required | Default | Meaning |
|---|---|---|---|
name |
yes | — | Identifier for the target; used in the branch, commit, PR title, and marker. |
repo |
yes | — | Upstream repo (owner/name) queried for the latest release/tag. |
file / files |
yes (one of) | — | Path(s) in this repo holding the pin; files bumps several in one PR. |
pattern |
pattern targets | — | Regex locating the pin; capture group 1 is the version substring rewritten in place (no group → the whole match is replaced). |
image |
image targets | — | Release image without a tag (ghcr.io/owner/app); selected containers are set to <image>:<tag>. Mutually exclusive with pattern. |
containers |
image targets | — | Names of the containers / initContainers to set, searched in every YAML document of every file. |
tag_template |
no | {version} |
Image tag written for a release; e.g. v{version} when the images keep the v. |
on_other_image |
no | replace |
What to do with a selected container that runs something other than a release of image: replace it with the latest release, or skip the target. |
pr_title |
no | chore: bump {name} to {version} |
PR title; {name} / {version} templated. |
branch_prefix |
no | deputy/release-watch |
Head branch is <branch_prefix>/<name>. |
labels |
no | ["dependencies"] |
Labels applied to the opened/updated PR. |
The upstream tag is normalised (a leading v is stripped) before it is spliced
into the captured span, so a v1.2.3 release lands as 1.2.3. Put any literal
v or quotes outside the capture group in your pattern.
deputy ships the semantic-release defaults itself (tag_format = "v{version}",
angular parser, main branch, push over origin, upload to the VCS release), so a
repo needs no action_config.toml / [tool.semantic_release] block. At
release time deputy renders a config from its defaults ⊕ your [release]
overrides and hands that to semantic-release. Override any key under [release]
(e.g. version_toml, tag_format, commit_parser_options).
| Key | Mechanism | Use it for |
|---|---|---|
version_toml |
TOML-aware, one exact key | pyproject.toml:project.version |
version_variables |
regex, replaces every match in the file | src/deputy/__init__.py:__version__ |
version_json |
JSON-aware, the package's own version only | package.json, package-lock.json |
The first two are semantic-release's own. version_json is deputy's, and exists
because the regex one cannot safely touch an npm lockfile:
[release]
version_toml = ["pyproject.toml:project.version"]
version_json = ["src/frontend/package.json", "src/frontend/package-lock.json"]version_variables builds the pattern <variable>\s*[:=]\s*"<semver>" and
substitutes every match. In a package-lock.json every dependency carries a
"version" key — on one real 230-dependency lock the pattern matches 471
times — so pointing it at a lockfile rewrites the whole dependency tree to the
project version. version_json parses the file instead and writes only the
package's own version:
- the root-level
"version"(package.json, and lockfileVersion 1, 2 and 3); and packages[""]["version"]— npm's empty-string key for the root package (lockfileVersion 2 and 3; a v1 lock has nopackagestable, so only the root field is written).
Nothing under dependencies, and no non-empty key under packages, is touched.
Notes:
- Paths only, no
file:fieldsuffix. Which fields to write is implied by the format — and a lockfile carries the version in two places at once, which no single pointer could express anyway. - Formatting is preserved byte for byte. The indent, newline style (LF/CRLF)
and trailing newline are detected and reproduced, so a bump is a two-line diff
rather than a whole-file reformat that
npm installwould undo. deputy verifies this by re-rendering the unmodified document first, and refuses to write if it does not come back identical. - It fails loudly. A missing file, invalid JSON, a missing or non-semver root
version, or a file that cannot be reproduced aborts the release with an error naming the file. Silence is how a lockfile drifts several minor versions behind. - It lands in the version commit. deputy writes and
git adds these files just before running semantic-release, which commits whatever is in the index — so they are part of the commit that gets tagged, not a dirty tree left behind. When semantic-release reports that no release is due, the files are left alone. version_jsonis stripped from the config handed to semantic-release, which knows nothing about it.
Note version_variables still works for package.json on its own — its own
"version" happens to be the only match — but the quoted form is required
('src/frontend/package.json:"version"'): the bare token version matches TOML's
version = "…" and never JSON, where the key carries a closing quote before the
colon.
deputy dogfoods its own CI: pr-review.yaml and tag-on-pr-merge.yaml install
this package and run it against deputy's own PRs. To cut a release, merge a PR
carrying exactly one release-* label (release-patch / release-minor /
release-major); tag-on-merge then bumps the version (both pyproject.toml
and src/deputy/__init__.py), tags vX.Y.Z, and publishes a GitHub Release.
A release-skip PR merges without cutting a tag.
A PR with no release-* label gets the repo's default — release-auto unless
[pr_review].default_label says otherwise. deputy's own deputy.toml keeps the
built-in, so forgetting the label releases at the level the commit history
implies rather than silently releasing nothing. Add the label you actually want
and deputy removes the default it applied, so the PR never ends up with the two
labels that would quietly suppress the release.
Logic in YAML can't be run, tested, or debugged locally. The bug deputy was born
from — an empty PR comment caused by semantic-release's newline-less tag= line
corrupting the next step's GITHUB_OUTPUT — needed a live PR and log forensics
to find. Here it's a one-line unit test
(test_version.py::test_isolated_env_strips_the_actions_output_handshake).
Pure decision logic with no I/O, plus thin injectable adapters:
labels.py release-* label -> bump decision (pure)
pr_checks.py conventional-title + one-label checks (pure)
comment.py render the sticky markdown body (pure)
gitops.py patch a container image in YAML text (pure)
release_watch.py semver compare + pinned-version rewrite (pure)
jsonversion.py npm-aware version rewrite, formatting-preserving (pure)
config.py deputy.toml loader, precedence, release defaults (pure)
version.py semantic-release wrappers (env-isolated, injectable runner)
actions_io.py read the event payload; write GITHUB_OUTPUT (heredoc-safe)
github.py GitHubClient protocol + REST impl (comments, labels, releases, PRs)
gitutils.py git helpers (injectable runner)
flows.py pr_review()/tag_on_merge()/gitops_update()/release_watch() — every side effect is a parameter
cli.py wire the real adapters from env/args/deputy.toml; argparse entrypoints
flows.py takes the GitHub client, file reader/writer, version calculator, and
git helpers as arguments, so every flow runs under pytest with in-memory fakes
(tests/fakes.py) — no GitHub, no git, no semantic-release, no Actions runner.
Prefer deputy.toml; these env vars exist for workflows and back-compat and take
precedence over it:
| Env | Purpose |
|---|---|
DEPUTY_TOML |
Path to deputy.toml (default ./deputy.toml). |
DEPUTY_MARKER |
Sticky-comment marker; overrides [pr_review].marker. Set to a previous bot's marker to keep an existing thread. |
DEPUTY_DEFAULT_LABEL |
Default release-* label for a PR carrying none; overrides [pr_review].default_label. Empty counts as unset. |
DEPUTY_CONFIG |
Use an existing semantic-release config file as-is instead of rendering one from [release] (back-compat). |
python -m venv .venv && . .venv/Scripts/activate # or bin/activate on POSIX
pip install -e . pytest ruff
pytest -q
ruff format --check src tests && ruff check src testsTests need nothing installed beyond the dev deps — tests/conftest.py puts
src/ on the path.
Add a command: a pure function or two for the decision, an adapter method if it
needs a new side effect, a flows.py function that takes those as parameters, a
cli.py subcommand that wires the real ones, and tests using the fakes. Keep
side effects out of the pure modules.