Reusable GitHub Actions for Calysto packages. All actions are available via the v1 floating tag:
uses: calysto/maintainer_tools/actions/<name>@v1The v1 tag always points to the latest stable commit and is updated automatically on each stable release.
Builds and inspects the Python package using hynek/build-and-inspect-python-package and exports supported Python versions derived from the package classifiers. Includes free-threaded builds by default.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
include-free-threaded |
No | "true" |
Whether to include free-threaded Python builds. |
Outputs
| Name | Description |
|---|---|
python-versions |
JSON array of supported Python versions derived from classifiers (e.g. ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]). |
Usage
jobs:
build:
runs-on: ubuntu-latest
outputs:
python-versions: ${{ steps.build.outputs.python-versions }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/build@v1
id: buildInstalls Python, Poetry (with OS-keyed cache), just, and project dependencies. This action should be the first step in any job that needs to build or test the package.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
python-version |
No | "" |
Python version to use. Defaults to the minimum version from pyproject.toml. |
poetry-version |
No | "pre-commit" |
Poetry version to install with pipx. The default resolves the version pinned by the poetry hook in .pre-commit-config.yaml (falling back to the latest Poetry when no such hook exists), keeping the lock update in sync with the poetry-check hook. Pass an explicit version to pin it, or an empty string to skip installing Poetry. |
Usage
- uses: actions/checkout@v6
- uses: calysto/maintainer_tools/actions/base-setup@v1
with:
python-version: "3.12"A full test matrix workflow using the build action to derive the supported Python versions:
jobs:
build:
name: Build & inspect package
runs-on: ubuntu-latest
outputs:
python-versions: ${{ steps.build.outputs.python-versions }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/build@v1
id: build
test:
name: Test (Python ${{ matrix.python-version }})
needs: [build]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJSON(needs.build.outputs.python-versions) }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/base-setup@v1
with:
python-version: ${{ matrix.python-version }}
- run: just testInstalls prek, runs prek auto-update with a configurable cooldown, and opens a pull request with the changes. Optionally generates a GitHub App token for authenticated pushes.
The poetry hook is always excluded — in whatever URL form the config uses (https, ssh, with or without .git). Its version is coupled to poetry.lock (via base-setup), so it is bumped by poetry-lock-update instead — that keeps the toolchain and the lock in sync and avoids autoupdate PRs that fail the poetry-lock hook. Pass exclude-repos to leave any additional repositories untouched.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
app-id |
No | "" |
GitHub App ID for authenticated pushes. Falls back to github.token if not provided. |
app-private-key |
No | "" |
GitHub App private key for authenticated pushes. |
cooldown-days |
No | "7" |
Minimum release age in days before updating to a new version. |
exclude-repos |
No | "" |
Space-separated list of additional hook repositories to leave untouched (passed through as --exclude-repo). The configured poetry hook is always excluded, in whatever URL form it uses. |
branch |
No | "pre-commit-autoupdate" |
Branch name for the autoupdate pull request. |
labels |
No | "maintenance" |
Labels to apply to the pull request. |
dry-run |
No | "false" |
If "true", passes --dry-run to gh pr create (no PR is actually opened). |
Usage
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/pre-commit-autoupdate@v1
with:
app-id: ${{ vars.APP_ID }}
app-private-key: ${{ secrets.APP_PRIVATE_KEY }}Typically used in a scheduled workflow:
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9am
permissions:
pull-requests: write
jobs:
autoupdate:
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/pre-commit-autoupdate@v1
with:
app-id: ${{ vars.APP_ID }}
app-private-key: ${{ secrets.APP_PRIVATE_KEY }}Runs poetry update under a minimum-release-age cooldown and opens a pull request with the poetry.lock changes — or, if a lock-update PR is already open, force-pushes the refreshed lock file to it instead of opening a duplicate. Requires base-setup to run before this action. Optionally generates a GitHub App token for authenticated pushes.
This action also owns the Poetry version: it bumps the poetry hook in .pre-commit-config.yaml to the newest release that satisfies the cooldown (via prek update --repo), reinstalls that Poetry, and regenerates the lock with it. The version bump and the lock change therefore land together in one PR, because Poetry stamps its own version into poetry.lock and base-setup derives CI's Poetry from the same hook. pre-commit-autoupdate leaves the poetry hook alone so the two never diverge.
If the cooldown blocks every version satisfying a dependency constraint, the resolve retries with the cooldown waived for just the blocking packages, up to 5 attempts. A genuine dependency conflict still fails the run. Waivers are listed in a ## Cooldown waived section of the pull request body; those packages are locked at versions that have not met the cooldown, so review them accordingly.
The pull request body lists the Poetry version bump (if any) under a ## Poetry version heading.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
app-id |
No | "" |
GitHub App ID for authenticated pushes. Falls back to github.token if not provided. |
app-private-key |
No | "" |
GitHub App private key for authenticated pushes. |
min-release-age-days |
No | "7" |
Minimum release age in days before Poetry's resolver will consider a version. |
branch |
No | "poetry-lock-update" |
Branch name for the update pull request. Reused across runs — an existing open PR on this branch is updated in place rather than duplicated. This branch is exclusively managed by this action and force-pushed on every run, so manual commits pushed to it will not survive. |
labels |
No | "maintenance" |
Labels to apply to the pull request. |
dry-run |
No | "false" |
If "true", no push or PR mutation happens: gh pr create runs with --dry-run when no PR is open, or a "would update" message prints when one is. |
Usage
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/base-setup@v1
- uses: calysto/maintainer_tools/actions/poetry-lock-update@v1
with:
app-id: ${{ vars.APP_ID }}
app-private-key: ${{ secrets.APP_PRIVATE_KEY }}Typically used in a scheduled workflow:
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 6am
permissions:
pull-requests: write
jobs:
lock-update:
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/base-setup@v1
- uses: calysto/maintainer_tools/actions/poetry-lock-update@v1
with:
app-id: ${{ vars.APP_ID }}
app-private-key: ${{ secrets.APP_PRIVATE_KEY }}Enforces that every PR has at least one of the required labels: bug, enhancement, dependencies, maintenance, documentation.
Inputs
None.
Usage
- uses: actions/checkout@v6
- uses: calysto/maintainer_tools/actions/enforce-label@v1Typically used in a workflow triggered on pull_request events:
on:
pull_request:
types: [labeled, unlabeled, opened, edited, synchronize]
jobs:
enforce-label:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: calysto/maintainer_tools/actions/enforce-label@v1Installs prek and runs pre-commit hooks with environment caching.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
extra-args |
No | "--all-files --hook-stage manual" |
Extra arguments passed to prek run. |
Usage
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/pre-commit-run@v1Typically used in a workflow triggered on push and pull_request events:
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/pre-commit-run@v1Pass extra-args to run only on changed files (e.g. in a PR context):
- uses: calysto/maintainer_tools/actions/pre-commit-run@v1
with:
extra-args: "--from-ref ${{ github.event.pull_request.base.sha }} --to-ref HEAD"Bumps the package version, updates CHANGELOG.md, commits the changes, creates a GitHub release, then bumps to the next .dev version. Supports dry-run mode for testing. Requires base-setup to run before this action.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
version |
Yes | — | Version to release: a version number (e.g. 1.0.0rc4) or one of: patch, minor, major, prepatch, preminor, premajor, prerelease. |
dry_run |
No | "false" |
If "true", creates a draft release then deletes it and does not push changes. |
app_id |
No | "" |
GitHub App ID for authenticated pushes (not required for dry runs). |
app_private_key |
No | "" |
GitHub App private key (not required for dry runs). |
changelog_body |
No | "" |
Custom release notes to use instead of the auto-generated changelog. See Providing custom release notes. |
ref |
Yes | — | Branch to push commits back to (ignored when dry_run is "true"). |
Outputs
| Name | Description |
|---|---|
tag |
The release tag created (e.g. v0.3.5), or the commit SHA on a dry run. |
Usage
- uses: actions/checkout@v6
- uses: calysto/maintainer_tools/actions/base-setup@v1
- uses: calysto/maintainer_tools/actions/release@v1
with:
version: ${{ inputs.version }}
dry_run: "false"
app_id: ${{ vars.APP_ID }}
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
ref: ${{ github.ref_name }}A full release workflow with build and PyPI publish steps:
jobs:
release:
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
outputs:
tag: ${{ steps.release.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/base-setup@v1
- uses: calysto/maintainer_tools/actions/release@v1
id: release
with:
version: ${{ inputs.version }}
dry_run: ${{ inputs.dry_run }}
app_id: ${{ vars.APP_ID }}
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
ref: ${{ github.ref_name }}
build:
name: Build & verify package
needs: [release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.tag }}
fetch-depth: 0
persist-credentials: false
- uses: calysto/maintainer_tools/actions/build@v1
publish:
needs: [build]
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
attestations: write
steps:
- name: Download packages built by build-and-inspect-python-package
uses: actions/download-artifact@v4
with:
name: Packages
path: dist
- name: Upload package to Test PyPI
uses: pypa/gh-action-pypi-publish@v1
with:
repository-url: https://test.pypi.org/legacy/
skip-existing: ${{ inputs.dry_run }}
- name: Upload package to PyPI
if: ${{ !inputs.dry_run }}
uses: pypa/gh-action-pypi-publish@v1Providing custom release notes
By default the action auto-generates the changelog from PR titles and labels. Pass changelog_body to supply your own release notes instead.
The GitHub Actions web UI only provides a single-line text field, so pasting multi-line markdown directly will have its newlines stripped by the browser. There are two ways to work around this:
-
Recommended — use the CLI.
gh workflow runaccepts real newlines:gh workflow run release.yml \ -f version=patch \ -f changelog_body="## Highlights MetaKernel 1.0 is a major release. ## New Features - DisplayData() for raw MIME bundle display (#211)"
-
Use
\nescape sequences in the web UI. The action converts literal\nstrings to real newlines, so you can type or paste a single-line string:## Highlights\n\nMetaKernel 1.0 is a major release.\n\n## New Features\n\n- DisplayData() for raw MIME bundle display (#211)
Pins all dependencies to their minimum allowed versions (as declared in pyproject.toml) and runs the test suite. Requires base-setup to run before this action.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
command |
No | "just test" |
Command to run the test suite. |
Usage
- uses: actions/checkout@v6
- uses: calysto/maintainer_tools/actions/base-setup@v1
- uses: calysto/maintainer_tools/actions/test-minimum-versions@v1
with:
command: "just test"Downloads the Packages artifact produced by hynek/build-and-inspect-python-package, unpacks the sdist, and runs the test suite from within it. Requires base-setup to run before this action.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
command |
No | "just test" |
Command to run the test suite from within the unpacked sdist directory. |
Usage
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/build@v1
test-sdist:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: calysto/maintainer_tools/actions/base-setup@v1
- uses: calysto/maintainer_tools/actions/test-sdist@v1
with:
command: "just test"Initializes CodeQL and performs security analysis for a given language. Wraps github/codeql-action/init and github/codeql-action/analyze.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
language |
Yes | — | Language to analyze (e.g. python, actions, javascript-typescript). |
build-mode |
No | "none" |
Build mode: none, autobuild, or manual. |
Usage
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/codeql@v1
with:
language: python
build-mode: noneTypically used in a scheduled CodeQL workflow with a language matrix:
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: '44 18 * * 3'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
if: github.event_name != 'schedule' || github.repository == 'your-org/your-repo'
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: python
build-mode: none
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/codeql@v1
with:
language: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}Runs zizmor GitHub Actions security analysis. If the calling repository does not have a .github/zizmor.yml config file, a bundled default config is used that pins actions/* and calysto/maintainer_tools/* references to a version tag.
Inputs
None.
Usage
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/zizmor@v1Typically used in a workflow triggered on push and pull_request events:
jobs:
zizmor:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: calysto/maintainer_tools/actions/zizmor@v1To customize the rules, add a .github/zizmor.yml to your repository — it will be used instead of the bundled default.
The v1 floating tag is updated automatically as part of the release workflow. After a stable release (any version without pre-release markers like a, b, rc, or dev), the update-v1-tag job will:
- Delete the existing
v1tag locally and remotely - Re-create
v1at the release commit - Push the updated tag
Pre-release versions (e.g. 1.0.0a1, 1.0.0rc2) will not update the v1 tag.