Skip to content

Repository files navigation

deputy

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"

Use it as a GitHub Action

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's github-actions ecosystem, which reads uses:. 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-token is set as both GITHUB_TOKEN and GH_TOKEN, because pr-review reads the first and tag-on-merge the 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).

Install it as a global CLI

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.1

pixi 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

Commands

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.

sshkey

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 secret

The 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).

gitops-update

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 target

Ad-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.3

Patches 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.

release-watch

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 branch

Per target deputy:

  1. Looks up the upstream repo's latest GitHub Release, falling back to its highest semver tag when no release is published.
  2. Reads the currently-pinned version out of your file via a regex with one capture group (group 1 is the version).
  3. If upstream is strictly newer (semver compare, leading v and 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 names

That 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).

Forgejo / Gitea consumers

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 = [].

Scheduled workflow (consumer side)

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 }}

deputy.toml

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

[pr_review] schema

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 yields to an explicit label

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-review against a PR with no release-* 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.

[[release_watch]] schema

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.

Release config is self-contained

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).

Where the version gets written

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 no packages table, so only the root field is written).

Nothing under dependencies, and no non-empty key under packages, is touched.

Notes:

  • Paths only, no file:field suffix. 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 install would 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_json is 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.

Releasing

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.

Why

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).

Design

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.

Environment overrides

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).

Develop & test

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 tests

Tests need nothing installed beyond the dev deps — tests/conftest.py puts src/ on the path.

Extending

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.

About

My repo's CI deputy — PR checks and release tagging as testable Python, not logic buried in Actions YAML

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages