Composite Actions check PR news, prepare a Towncrier release, verify its patch, push an App-authored commit and tag, and render GitHub Release notes. The reusable Python release workflow builds and checks wheels and sdists. Each repository keeps its dispatch form, publisher, GitHub environment, and required check names.
install-locked-tools creates a virtual environment and installs selected groups from the caller's pylock.toml by
default. lock-source: shared selects this Action repository's pylock.toml for shared jobs such as hook maintenance
that do not need the caller's release tools. The caller's full project lock records runtime dependencies, every extra,
and every dependency group across supported Python versions and platforms. CI resolves test, type, and documentation
dependencies fresh; release jobs install only the selected release groups. The Action rejects missing groups and leaves
tracked files unchanged. Source builds use constraints generated from the lock's build group under the runner's
temporary directory.
Select the groups each job needs. The shared workflows use release-news release-shell,
release-hook-check release-notes lock-reader, and release-prepare project-runtime or
release-validate project-runtime for release-creator's own build. A full lock records each group and extra, but a new
combination still needs an install check before a caller uses it.
venv: directory for the virtual environment whengroupsis set.groups: whitespace-separated release dependency groups. Leave empty to export build constraints without installing release tools.lock-source:caller(default) installs from the checkout's lock;sharedinstalls from the pinned Action's lock. Both paths use the pinned Action's lock-tool bootstrap. The Action checks that every selected group exists in the selected lock and applies hashed build constraints.local-tool-source: set totrueonly when release-creator builds and installs its own wheel after this Action. The caller's lock then needs the selected groups but does not need a release-creator package entry.constraints-lock: lock used to export build constraints; defaults to the selected install lock. Use a secondary lock when the build matrix differs from the release-tool matrix.verify-lockandverify-groups: check another lock's selected packages against PyPI and the seven-day cutoff without installing them. Use this before bundling packages from a separate runtime lock, such as a zipapp.target-platformandtargets: Nab platform ID and space-separatedfull-version:implementationpairs for build constraints. Pass both when exporting constraints for a specific build target; otherwise the runner selects the target.
The build-constraints output names the hashed constraints file. The Action adds installed command launchers to
GITHUB_PATH for later steps while leaving the runner's python, pip, and uv commands on their original paths. Run
actions/checkout and astral-sh/setup-uv with Python 3.15 before it. It bootstraps its lock reader from the shared,
hash-checked lock.
For a zipapp that bundles wheels from a separate runtime lock, check that lock in the same job before building it:
- uses: tox-dev/github-workflows/install-locked-tools@SHA
with:
groups: release-build
venv: ${{ runner.temp }}/release-tools
verify-lock: pylock.zipapp.toml
verify-groups: project-runtimeexport-build-constraints also accepts verify-groups. Pass it when its secondary lock supplies build tools outside
the build group, such as profile-guided optimization tools in a pgo group.
validate-news reads tool.towncrier from pyproject.toml. It checks every added or modified fragment in the
configured directory against the configured categories, filename format, and nonempty content. A valid fragment runs
towncrier check against the PR base. A human PR without a fragment needs skip news; a bot PR without a fragment does
not. A bot's supplied fragment is checked and included in the normal release flow. The Action reads repository files and
Git history; it does not add labels or write to GitHub.
Put the changelog job in a separate news.yaml workflow triggered by opened, reopened, synchronize, labeled,
and unlabeled. Require its status in the main-branch ruleset alongside the test aggregate. A label change then runs
only the news workflow. Install Towncrier from the caller's release lock before calling the Action:
changelog:
runs-on: ubuntu-26.04
permissions:
contents: read
steps:
- uses: actions/checkout@CHECKOUT_SHA
with:
fetch-depth: 0
persist-credentials: false
- uses: astral-sh/setup-uv@SETUP_UV_SHA
with:
version: latest-known
python-version: '3.15'
- uses: tox-dev/github-workflows/install-locked-tools@SHA
with:
groups: release-news
venv: ${{ runner.temp }}/release-tools
- uses: tox-dev/github-workflows/validate-news@SHAThe Action expects a pull request base ref and a Markdown or reStructuredText changelog. The caller should also run on
labeled and unlabeled events, so a label added after opening the PR updates the required check.
Call upgrade-release-tools.yaml from a scheduled or manual workflow. Its read-only jobs refresh the universal
pylock.toml and the frozen pre-commit hooks in parallel. release-creator locks exports runtime dependencies, all
extras, and all dependency groups with a seven-day package cutoff. Reviewed exceptions live in
[tool.release-creator.locks.exclude-newer-package]. Both lock checks and weekly refresh install uv from the shared
release-resolver group, so a setup-uv default change cannot alter the generated lock. The shared weekly updater
refreshes this direct pin and the lock together; consumer locks use their pinned shared resolver. The hook updater
applies the same cooldown to hook revisions and their additional_dependencies. Each job uploads a patch. The lock
patch may change pylock.toml, an explicitly configured secondary Nab lock, and pyproject.toml for expired reviewed
cutoffs. The hook patch may change only .pre-commit-config.yaml. The writer checks each patch before applying it and
verifies cutoff removals before minting the App token. It then opens or updates one dependency PR from
automation/update-release-tools to main. A lock or hook update runs the normal PR checks before merge.
The writer uses a repository-only release App with Contents and Pull requests write access. Install the App on this
repository, set RELEASE_APP_CLIENT_ID as a repository variable, and store RELEASE_APP_PRIVATE_KEY in the
release-auth environment. Restrict that environment to main. Create dependencies and skip news labels, and allow
the App to push the dependency branch. The caller supplies the secret by name; GitHub requires this mapping even when
the key exists only in the writer job's environment.
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 }}lock-groups remains an input for callers with an older Nab primary lock; the full project exporter ignores it and
records every dependency group and extra. Tests install test, type, and documentation tools afresh to detect upstream
dependency changes. The updater also refreshes a caller's secondary Nab build-matrix lock when additional-lock and its
matrix inputs are set.
The optional commit-message, pull-request-title, and pull-request-body inputs retain existing PR wording. The
workflow updates a committed uv.lock only when keep-uv-lock: true; pass that input to validate-news too. Both
locks are then checked with the same resolver and allowed in the dependency patch. The default leaves uv.lock alone.
Build constraints are generated from the lock's build group at runtime.
For a second lock such as pylock.build.toml, set additional-lock, additional-matrix-python,
additional-matrix-platforms, and additional-matrix-implementations. additional-lock-groups selects dependency
groups in that lock; with no groups, the updater selects build requirements. Set additional-lock-selection: project
for a lock that selects the project's runtime dependencies instead. The lock must already exist, and the updater writes
both locks in the same dependency PR. The writer accepts only those locks, an opted-in uv.lock, pyproject.toml, and
the hook configuration. Pass the same additional-lock and matrix inputs to validate-news in the PR workflow. It
checks changes to either lock against the project, including a PR that changes only the secondary lock.
refresh-release-lock is the read-only half of the updater. In refresh mode, it upgrades the full primary lock and
writes $RUNNER_TEMP/lock.patch if a lock or reviewed cutoff changes. In check mode, it regenerates at the primary
lock's recorded creation time and fails if the checked-in files differ. validate-news calls this mode when a PR
changes release inputs, including a workspace member's pyproject.toml or a secondary lock. The resulting PR's normal
formatting job runs prek; the writer checks patch paths and modes. Its changed output is true only when the
refresh produces a patch. Neither mode pushes a branch or requests a write token.
refresh-mise-pin updates one named jdx/mise-action step in a release workflow. It selects the newest stable mise
GitHub Release whose publication and Linux executable and archive uploads are at least seven days old. Those assets must
also be unchanged for seven days. The Action installs that version through the pinned mise Action and compares the
executable with the release asset digest. Before invoking mise, the mise Action verifies its downloaded archive against
the minisign-signed checksum file. The refresh Action fails if the named step is missing or duplicated, its version or
digest is not literal, or the installed binary differs from the selected release. It changes only that step's version
and sha256 values. The verified mise executable is on PATH and at $RUNNER_TEMP/refresh-mise-pin/bin/mise for the
next step; export_path: false disables mise-generated paths, not the binary's path.
Call it in a read-only Ubuntu job after checkout and setup-uv with Python 3.15. The Action installs its YAML parser from
the shared hash-checked lock. workflow is the path under .github/workflows; step-name is the exact name of the
mise step in that file. github-token needs public release read access. changed is true only when the two pins
change; version omits the leading v, and sha256 is the Linux x64 executable digest. The Action does not update
mise.lock or open a PR.
- uses: tox-dev/github-workflows/refresh-mise-pin@SHA
id: mise
with:
workflow: .github/workflows/release.yaml
step-name: 🔧 Set up mise (JDKs)
github-token: ${{ github.token }}
- name: Refresh locked JDKs
run: >-
$RUNNER_TEMP/refresh-mise-pin/bin/mise lock --bump --minimum-release-age 7dInclude .github/workflows/release.yaml and mise.lock in the updater's artifact and App writer path allowlists. The
writer should run in a separate job; this Action does not request its token. The seven-day cutoff is fixed and cannot be
lowered by an input.
The weekly updater freezes eligible hook revisions with prek update --freeze --cooldown-days 7. It then uses
release-creator hooks to pin Python hook dependencies under the same cutoff and writes a patch when
.pre-commit-config.yaml changes. The generation job has read-only repository permissions; the separate publish job
checks the patch paths and file modes before obtaining an App token.
check-hook-update-cooldown checks every PR that changes .pre-commit-config.yaml. Put it in the caller's
pull_request workflow with actions: read, contents: read, and pull-requests: read, then include it in the
required aggregate. It reads its PR number and head SHA from the event, compares them with the live PR, and reads the
proposed configuration from that exact head, including a fork head. It checks the complete Python hook dependency
closure at the configured minimum-days cutoff and requires each changed revision to have appeared in a GitHub PR
workflow run at least that many days earlier. The default is seven days. A force push does not reset an unchanged
revision's age or give a new revision the old one's age. GitHub can omit pull request links from workflow runs. For
those runs, the Action observes the exact head repository, branch, commit, and workflow after the PR opened; two PRs
from the same head can share that code observation. Human PRs may add hooks or change their options under the same
revision and dependency checks. A human PR may add the first managed pins when the base configuration has none; the
complete closure check still applies. Once the base has managed pins, only the release App may change them. Other bot
PRs may change revisions only. On automation/update-release-tools, the Action verifies the bot against
RELEASE_APP_CLIENT_ID and permits exact, cooled PyPI dependency pins generated by the release App. Every external hook
revision must be a full lowercase commit SHA. A revision with new Python dependencies stays blocked until the App
updates its managed pins. Tagged source jobs check frozen revisions and the complete Python dependency closure before
running prek.
On a tag, verify-frozen-hooks also checks when each new external (repository, commit) pair was first present in
GitHub. It compares the tag with the previous published release in the same tag stream and on the first-parent release
line. A new pair must come from a merged PR, and either its merge or a PR run containing the exact pair must be at least
seven days old. The first release has no baseline: every external pair needs that proof. Directly pushed hook changes
cannot acquire it. This read-only Action has no outputs; it fails before prek runs. The tagged source job needs
actions: read, contents: read, pull-requests: read, and a checkout with fetch-depth: 0.
For a release dispatched from main with a tag input, pass that tag to the Action. It checks that the checkout is the
tag's commit; a dispatch on main with the wrong checkout fails.
- uses: tox-dev/github-workflows/verify-frozen-hooks@SHA
with:
tag: ${{ inputs.tag }}The shared prepare and tagged validator run refresh-release-lock in check mode before release checks or publishing.
Pass keep-uv-lock: true when the repository commits a native uv.lock. For a secondary Nab lock, pass its path,
selected groups, selection type, and matrix inputs to both workflows. A stale primary, native, or secondary lock stops
the release before artifacts are built.
For example, a tagged source job that also publishes from a PGO lock checks both files before installing build tools:
- uses: tox-dev/github-workflows/refresh-release-lock@SHA
with:
mode: check
additional-lock: pylock.pgo.toml
additional-lock-groups: pgo
additional-matrix-python: '>=3.11,<3.16'
additional-matrix-platforms: linux_x86_64 linux_aarch64
additional-matrix-implementations: cpython pypyFor a committed native uv lock, set keep-uv-lock: true in this step. The full project lock and uv.lock must then
agree with the same project configuration.
hook-cooldown:
if: github.event_name == 'pull_request'
runs-on: ubuntu-26.04
permissions:
actions: read
contents: read
pull-requests: read
steps:
- uses: tox-dev/github-workflows/check-hook-update-cooldown@SHA
with:
release-app-client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}Run the PR workflow on opened, reopened, and synchronize; the cooldown check must succeed on PRs and may skip on
push. The Action has no output or write side effect. Schedule recheck-hook-update-cooldown daily with
actions: write, contents: read, and pull-requests: read. It reruns eligible failed cooldown jobs at the current
head, including human and fork PRs. GitHub accepts reruns for 30 days. A failed rerun is retried on the next schedule
while eligible. The caller here shows the trigger and permissions. The
Action reruns the hook-cooldown job in check.yaml.
If the job has a different name or workflow filename, pass both to the scheduled caller:
with:
workflow: ci.yaml
job-name: check-hook-updatesThe release preparation workflow in this repository calls the reusable
Towncrier workflow. Its assemble job uploads a patch; a separate keyless validate job downloads and checks that
artifact. The push job downloads the same artifact by ID and checks its paths and modes before minting a
repository-scoped App token. Set the tag style in the caller and the changelog path and fragment directory in committed
Towncrier configuration. Pin the reusable workflow to a release commit SHA; SHA in these examples means that commit.
Assembly needs release history to select a tag. The writer can check out the source commit with fetch-depth: 1. All
jobs use persist-credentials: false. Assembly installs the caller's locked release tools and needs contents: read
and pull-requests: read to resolve PR authors. It starts prek install-hooks while building the changelog, then waits
for the hooks before formatting and uploading the patch. The validation job runs prek on that uploaded patch. The
writer uses the release-auth environment with RELEASE_APP_PRIVATE_KEY and RELEASE_APP_CLIENT_ID. Restrict that
environment to main. Install the App on this repository only, with Contents write and Metadata read, and grant the
necessary branch and tag ruleset bypass.
main + fragments
│
▼
prepare-towncrier ──► upload patch ──► validate artifact
│ │
└─────────┬─────────┘
▼
verify paths and modes
│
▼
mint App token ──► commit-and-tag ──► main + tag
The tag starts the caller's publisher. Keep PyPI trusted publishing in that top-level workflow, in a separate job with
id-token: write because
PyPI disallows reusable workflows. Run prek in a separate
read-only job alongside the tagged-source build, and make publishing depend on both. Repositories with the standard uv
wheel and sdist build can call validate-python-release.yaml in the tag publisher. The
self-release workflow shows the GitHub Release path; callers keep their ecosystem
uploads. The read-only build workflow checks the tagged source. The tag caller renders the Release body and has the only
Contents write job that creates the GitHub Release.
validate-python-release.yaml checks the tagged source, exports hashed build constraints in a separate job, builds and
uploads the wheel and sdist, then checks the uploaded files with Twine and check-wheel-contents. The build job runs no
release tool. It requires the tag ref to resolve to the checked-out commit on main.
build:
uses: >-
tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@SHA
permissions:
actions: read
contents: read
pull-requests: readThe caller needs release-check for prek and a build group for backend constraints. source defaults to .. Use
artifact-prefix when one tag produces more than one distribution path; each call then writes a distinct
python-package-distributions artifact. A caller pinned to a workflow commit runs that commit's local Actions.
Publish only after the reusable workflow succeeds. Download the distribution with pinned download-artifact
v8.0.2,
using artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }}. The download checks the artifact digest and
selects the wheel and sdist from that build run. Render GitHub notes in the caller's tag workflow after fetching
GitHub's contributor metadata; this reusable workflow does not render notes. Keep PyPI OIDC in the caller's release
environment job.
build-python-distribution remains available for existing keyless PR build jobs. It checks that dist/ is empty,
exports hashed build constraints from the caller's lock, and writes a wheel and sdist to dist/. It does not upload or
publish them. Tagged release jobs use validate-python-release.yaml so lock and release tools run in a separate job
from the distribution build.
- uses: tox-dev/github-workflows/build-python-distribution@SHAThe Action builds the checkout root and has no named output; subsequent steps read dist/.
export-build-constraints reads pylock.toml by default. Pass lock for a secondary lock such as pylock.build.toml,
plus target-platform and targets when the build matrix needs constraints for specific interpreters. The output
path names a temporary requirements file with hashes. The Action writes no tracked files.
- id: constraints
uses: tox-dev/github-workflows/export-build-constraints@SHA
with:
lock: pylock.build.toml
target-platform: linux_aarch64
targets: 3.15.0:cpythontargets is a space-separated list of full-version:implementation pairs. All selected targets must resolve the same
build package versions; otherwise the Action fails instead of writing an ambiguous constraints file.
prepare-towncrier-release.yaml runs three jobs. assemble selects a tag and uploads a Towncrier patch without the App
key. validate calculates the next tag again from the checked-out source, then applies the uploaded artifact and runs
prek. push downloads that artifact by ID, checks its paths and file modes, reads the caller's release-auth
environment, and uses a repository-scoped App token to push the release commit and tag together. Only a dispatch from
main can reach the writer.
jobs:
prepare:
if: github.ref == 'refs/heads/main'
uses: >-
tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@SHA
permissions:
contents: read
pull-requests: read
with:
bump: ${{ inputs.bump }}
tag-prefix: v
annotated: true
secrets:
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}The caller supplies the release-auth environment secret by name; the App client ID comes from that environment's
RELEASE_APP_CLIENT_ID variable. The App token requests Contents write for this repository only. The caller grants its
GitHub token Contents and Pull requests read. Assembly installs release-prepare, which contains release-creator,
Towncrier, and prek. No job output crosses back to the caller. A successful run pushes a tag, which the caller's tag
workflow handles.
bump, tag-prefix, and changelog-prefix have the same meanings as the prepare-towncrier inputs below. annotated
selects an annotated tag. release-auth-deployment defaults to true; set it to false if the current App-key job
uses release-auth without creating a GitHub Deployment. local-tool-source: true builds release-creator's wheel from
the checkout and installs it with locked third-party dependencies in assembly; use it for release-creator itself. The
caller can place repository-specific checks in a preceding job and make this job depend on it. One dispatch prepares one
tag; run separate dispatches for separate packages. The tag-triggered ecosystem publisher stays in the caller
repository.
read-towncrier-config reads towncrier.toml or pyproject.toml from the checkout's committed HEAD. It requires
normalized changelog and fragment paths, a Markdown or RST changelog suffix, and explicit fragment categories. It reads
committed bytes so an untrusted release patch cannot change the allowed paths or categories.
- id: towncrier
uses: tox-dev/github-workflows/read-towncrier-config@SHA
- env:
CHANGELOG: ${{ steps.towncrier.outputs.changelog }}
run: printf '%s\n' "$CHANGELOG"Outputs are changelog, fragments, and format (markdown or rst). The Action does not modify the checkout or
GitHub state.
prepare-towncrier chooses the next version, links fragments to their squash PRs, runs Towncrier and the selected
formatter hook through prek, then writes a binary Git patch at $RUNNER_TEMP/release.patch. It reads the changelog
path, fragment directory, categories, and Markdown or RST format from committed Towncrier configuration through
read-towncrier-config. It does not commit or push. Version selection, fragment linking, the Towncrier build, and
formatting must all succeed.
- id: prepare
uses: tox-dev/github-workflows/prepare-towncrier@SHA
with:
bump: ${{ inputs.bump }}bump:auto(default),major,minor,patch, orcalver. Passed torelease-creator tags --next;autouses the pending fragments. The caller's dispatch form decides which choices to expose.tag-prefix: Empty by default. Usevwhen release tags are spelledv1.2.3; version comparison still includes unprefixed numeric tags in the repository's history. This does not set the changelog heading prefix.changelog-prefix: Empty by default, so Towncrier writes1.2.3even if the tag isv1.2.3. Setvfor a changelog whose release headings usev1.2.3.version: Explicit version for a caller with its own version selector. It takes precedence overbump; the Action still checks that it is newer than existing tags.phase:all(default) runs the complete Action. The reusable workflow usesbuildto select the version, link authors, and build the changelog while hooks install; it then usesfinalizeto format, render, and write the patch. Pass the version selected bybuildtofinalize.
The committed tool.towncrier configuration must give a normalized filename, directory, and explicit fragment
categories. The filename must end in .md or .rst; mdformat or docstrfmt must be a configured prek hook for
that format. The Action writes the patch at $RUNNER_TEMP/release.patch; the reusable workflow uploads it by artifact
ID.
Output: version is the selected tag string. Use steps.prepare.outputs.version as a job output and pass it to
both later Actions. Working-tree effect: Towncrier changes the changelog and consumes fragments; the patch records
those changes.
validate-release-patch runs in the caller's fresh checkout of the original branch commit. It rejects a main branch
that moved, applies the uploaded patch, checks its changed paths and file modes, then asks release-creator to verify
that every deleted file is a configured Towncrier fragment. It renders the requested version and runs prek. It leaves
the patch staged for the commit Action. Run it in a keyless validation job. The App-key job repeats the branch, path,
and mode checks with checks: "false", without installing release tools.
- uses: tox-dev/github-workflows/validate-release-patch@SHA
with:
version: ${{ needs.assemble.outputs.version }}
patch: ${{ runner.temp }}/release-patch/release.patchversion(required): Exact tag chosen by preparation.release-creator notesmust find one valid section for it.patch(required): Local path of the downloaded patch artifact, not an artifact name or URL.checks:true(default) runsrelease-creatorandprek;falseonly applies and checks the validated patch.
Output: No named output. Working-tree effect: Applies and stages the patch. The Action reads the changelog path,
fragment directory, and categories from committed Towncrier configuration. Only the changelog and direct fragment
deletions may appear in the patch. A failed prek run or an unexpected path or mode fails the keyless job.
commit-and-tag accepts only a staged release patch in a clean working tree. It commits with the GitHub App bot
identity, creates the tag, and atomically pushes the branch and tag to origin. A rejected push leaves neither remote
ref updated. Mint the token after validation with actions/create-github-app-token, narrowed to this repository and
Contents write. Put commit-and-tag last in the writer job: its Git credential helper remains available to later steps.
- id: token
uses: actions/create-github-app-token@TOKEN_ACTION_SHA
with:
client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
- uses: tox-dev/github-workflows/commit-and-tag@SHA
with:
version: ${{ needs.assemble.outputs.version }}
token: ${{ steps.token.outputs.token }}
app-slug: ${{ steps.token.outputs.app-slug }}
message: release ${{ needs.assemble.outputs.version }}version(required): Tag name to create, such as1.2.3.token(required): Short-lived installation token from the repository-only App. The Action never reads the key.app-slug(required):app-slugoutput from the token Action, used to resolve the bot's Git identity.message(required): Commit message. It is also the annotated tag message whenannotatedistrue.annotated:false(default) creates a lightweight tag;truecreates an annotated tag. Match the repository's existing release history.
Output: No named output. Remote effect: One new commit on main and one tag at that commit. The App needs a
branch and tag ruleset bypass. A stale branch, invalid tag, or ruleset rejection fails the push.
render-release-notes checks the tag and its changelog section, finds the previous tag, and renders an unwrapped GitHub
Release body with release-creator. Call it on the actual tag. The publisher still fetches GitHub's generated notes and
creates the Release; this Action only writes a local Markdown file.
- uses: tox-dev/github-workflows/render-release-notes@SHA
with:
tag: ${{ github.ref_name }}
changelog: CHANGELOG.md
artifact-url: https://pypi.org/project/example/${{ github.ref_name }}/
generated-notes: ${{ runner.temp }}/generated-notes.md
output: ${{ runner.temp }}/release-notes.mdtag(required): Release version, such as1.2.3. The checkout must contain its changelog section.changelog(required): Tracked Markdown or reStructuredText changelog in the format thatrelease-creator notesparses, with exactly one section for the tag.artifact-url: Ecosystem release URL, such as a PyPI version page. Omit it for a GitHub-only project.artifact-label: Link text forartifact-url; defaultPyPI release. UseZipappor another product name for a different artifact.generated-notes: Path to GitHub's generated-notes body. The tag publisher fetches it after PyPI publication. It preserves New Contributors and fills any missingby @handlecredit. Omit it when the release has no GitHub-generated contributor metadata.output(required): Writable path for the GitHub Release body. Pass it togh release create --notes-fileonly after the ecosystem publisher succeeds.
The Action reads GITHUB_REPOSITORY for PR and comparison links. A repository with separate release namespaces, such as
toml-fmt, calls release-creator notes --package directly for its package changelog.
Output: No named output. Working-tree effect: The Action writes only output. It rejects an invalid tag, a
missing heading, an empty body, or a missing comparison link when an earlier tag exists. It neither fetches generated
notes nor writes a GitHub Release, so a PR job needs only read permissions. Fetching generated notes uses GitHub's
release endpoint and needs Contents write in the tag publisher.
select-previous-tag finds the preceding numeric tag using release-creator's version rules. The caller must check out
the tag history and install release-creator first. It reads Git tags and writes one step output; it does not change the
checkout or GitHub state.
- id: previous
uses: tox-dev/github-workflows/select-previous-tag@SHA
with:
tag: ${{ github.ref_name }}tag is required. Install release-creator on PATH before calling the Action. The previous output is empty when
this is the first matching release. Pass it as previous_tag_name to GitHub's generated-notes endpoint only when
nonempty.
reconcile-pypi-artifacts compares the complete validated distribution directory with PyPI's Simple JSON API and
verifies a trusted-publisher attestation for each file. Upload the complete validated directory with the pinned PyPI
publisher and skip-existing: true. A rerun sends only files missing from PyPI. Run this Action in a read-only job
after the publisher. A different digest in an already published file, unexpected or yanked file, malformed response, or
invalid attestation fails. The GitHub Release writer must depend on this verification.
- uses: tox-dev/github-workflows/reconcile-pypi-artifacts@SHA
with:
project: example
version: ${{ github.ref_name }}
dist: distproject, version, and dist are required. The Action installs the locked release-attestations group on Python
3.15 and waits up to 120 seconds for PyPI's listing to propagate. It checks the complete local file set, then runs
pypi-attestations verify pypi on each local file against the caller repository. It prints no credentials.
publish-github-release verifies the tag and its rendered notes before writing a GitHub Release. Pass the exact notes
file and the tag's commit SHA. A retry resumes a matching draft, or succeeds without a write when the published Release
already matches. Run it in a Contents-write job that downloads the notes and optional assets; it needs no checkout or
tool installation. For a repository that must upload GitHub assets before PyPI, call it with mode: stage, verify
PyPI's exact artifact set, then call it with mode: publish and the same assets-directory. The draft stays private
until the second call succeeds. Enable immutable releases in the repository before using this Action. Drafts can still
be edited, so the Action checks them again before upload and publication. A matching published release whose API record
is immutable needs no write or second metadata fetch. Older mutable releases retain the recheck.
- uses: tox-dev/github-workflows/publish-github-release@SHA
env:
GH_TOKEN: ${{ github.token }}
with:
tag: ${{ github.ref_name }}
commit: ${{ github.sha }}
title: ${{ github.ref_name }}
notes-file: ${{ runner.temp }}/release-notes.mdtag,commit,title, andnotes-fileare required. The Action uses the caller repository;commitis the commit the existing tag must resolve to.titleand the file bytes must match an existing Release exactly.assets-directory: Optional directory containing only the assets to attach. The Action compares each filename and SHA256 digest with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. After publication it verifies GitHub's release attestation for each local asset.prerelease:trueorfalse; defaultfalse. It must match an existing Release.mode:complete(default) creates or resumes a draft and publishes it in one call.stagecreates or resumes a draft and uploads assets without publishing.publishrequires an existing draft and publishes it after checking the tag, notes, and complete asset set again.
Output: No named output. Remote effect: Depending on mode, the Action creates or resumes a draft, uploads
missing assets, and publishes only after the complete asset set matches. A matching published Release causes no write. A
different body, title, tag commit, prerelease flag, or asset set fails without changing the existing Release. A failed
upload leaves the draft for a later retry. The Action removes incomplete starter assets before retrying. It retries
reading the release by ID for up to ten seconds after draft creation or asset upload; a mismatch still fails when that
window ends.
publish-post-release-pr consumes a candidate commit prepared without an App token. The preparation job records the
base commit, desired branch, and a tab-separated file of changed paths and Git blob SHA1 values, then creates a Git
bundle containing refs/heads/release-candidate and its full history. A thin bundle fails verification in the writer's
empty bare repository. Upload the bundle, path file, and body as one artifact. Pass the upload step's artifact ID to the
writer's download step, so a rerun cannot select an older artifact with the same name. The writer runs without checkout
under the caller's release-auth environment and needs Contents, Pull requests, and Issues write access for its App
token. Put publish-post-release-pr last in the writer job because its Git credential helper remains available to later
steps.
- uses: actions/download-artifact@DOWNLOAD_SHA
with:
artifact-ids: ${{ needs.prepare.outputs.artifact-id }}
path: prepared/
- uses: tox-dev/github-workflows/publish-post-release-pr@SHA
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
with:
app-slug: ${{ steps.app-token.outputs.app-slug }}
base-commit: ${{ needs.prepare.outputs.base-commit }}
branch: post-release-${{ github.ref_name }}
title: Bump version to 2.4.8-dev
commit-message: Bump version to 2.4.8-dev after 2.4.7
body-file: prepared/body.md
paths-file: prepared/paths.tsv
allowed-paths: gradle.properties
bundle: prepared/candidate.bundle
candidate-commit: ${{ needs.prepare.outputs.candidate-commit }}The Action derives the bot identity from the writer's App slug and verifies the commit author and committer, exact
commit message, single-parent commit, changed path set against the writer's allowed-paths, regular file modes, and
blob identities before any write.
Pass the keyless job's base-commit, the desired branch and title, its exact one-line commit-message, and the
exact PR body in body-file. paths-file contains one tab-separated path and desired Git blob SHA1 per line. The
Action targets main in GITHUB_REPOSITORY and applies skip news to the PR.
A matching open branch and PR need no second push. If a matching branch has no PR, it creates one. A merged PR with a
deleted branch succeeds only when the current base still has the expected file bytes. A closed, unmerged PR fails before
the Action creates a replacement branch or PR; reopen the PR before retrying. A different branch, PR title, body, base,
path set, or merged file state fails without a force-push. When the prepared change has already merged and produced no
new commit, pass empty bundle and candidate-commit values with the same path manifest; the Action still checks the
merged PR and current base. The Action has no named output.
sign-jetbrains-plugin signs an unsigned plugin ZIP after a keyless Gradle buildPlugin job. Run it in a fresh job on
ubuntu-26.04 with the signing environment, no checkout, and no Gradle invocation. Download the unsigned artifact and
pass the SHA256 emitted before that artifact was uploaded. The Action checks the file and its digest, downloads
JetBrains Marketplace ZIP Signer 0.1.43 from Maven Central, verifies its pinned SHA256, and uses the runner's Java 17
installation. It accepts plain PEM or base64 credentials and an encrypted private key with its password. The private key
is mapped only into the sign step's environment. Its temporary file is removed when that step ends.
sign:
needs: build-unsigned
runs-on: ubuntu-26.04
environment: release
permissions: {}
outputs:
archive: ${{ steps.signed.outputs.archive }}
sha256: ${{ steps.signed.outputs.sha256 }}
steps:
- uses: actions/download-artifact@DOWNLOAD_SHA
with:
name: unsigned-plugin
path: dist/
- id: signed
uses: tox-dev/github-workflows/sign-jetbrains-plugin@SHA
with:
archive: dist/Plugin-1.2.3.zip
sha256: ${{ needs.build-unsigned.outputs.archive_sha }}
certificate-chain: ${{ secrets.CERTIFICATE_CHAIN }}
private-key: ${{ secrets.PRIVATE_KEY }}
private-key-password: ${{ secrets.PRIVATE_KEY_PASSWORD }}
- uses: actions/upload-artifact@UPLOAD_SHA
with:
name: plugin-distribution
path: ${{ steps.signed.outputs.archive }}
if-no-files-found: errorarchive and sha256 identify the unsigned ZIP. certificate-chain and private-key supply the signing identity;
private-key-password is needed only for an encrypted key. The outputs are archive, the signed ZIP path, and
sha256, the digest to check after download in the publisher and GitHub Release jobs. Remote effect: downloads the
pinned signer JAR. It makes no GitHub or Marketplace write. The caller uploads the signed ZIP as a GitHub Actions
artifact after the Action succeeds. Keep the Marketplace token in a different no-checkout job.
publish-jetbrains-marketplace uploads one signed ZIP through the
Marketplace upload API. Run it in a fresh job
without checkout or Gradle. The release workflow must serialize runs for the same tag. Download the signed ZIP and
release notes by the artifact IDs from their producing jobs. The Action checks both before using the upload token.
marketplace-publish:
needs: [sign, notes]
runs-on: ubuntu-26.04
permissions:
actions: read
deployments: read
environment: release
steps:
- uses: actions/download-artifact@DOWNLOAD_SHA
with:
artifact-ids: ${{ needs.sign.outputs.artifact-id }}
path: dist
- uses: actions/download-artifact@DOWNLOAD_SHA
with:
artifact-ids: ${{ needs.notes.outputs.artifact-id }}
path: ${{ runner.temp }}/notes
- name: Publish signed ZIP
uses: tox-dev/github-workflows/publish-jetbrains-marketplace@SHA
with:
archive: dist/Plugin-1.2.3-signed.zip
sha256: ${{ needs.sign.outputs.sha256 }}
plugin-id: '29973'
channel: ''
tag: ${{ github.ref_name }}
commit: ${{ github.sha }}
signed-artifact-id: ${{ needs.sign.outputs.artifact-id }}
notes-artifact-id: ${{ needs.notes.outputs.artifact-id }}
notes-file: ${{ runner.temp }}/notes/release-notes.md
github-token: ${{ github.token }}
publish-token: ${{ secrets.PUBLISH_TOKEN }}channel is empty for Stable; use a Marketplace channel name for a prerelease. Keep the publishing step named
Publish signed ZIP. The Action recognizes that name and two older upload step names in prior runs and deployment jobs.
Keep those names in the Action when adding another. Give the job Actions and Deployments read access and the workflow a
concurrency group keyed by tag. Use the release environment on the upload job. The Action verifies the remote tag,
then searches earlier tag runs and release deployments. For the same commit, an acceptance receipt must match the
original artifact IDs, signed ZIP digest, and release notes bytes before the Action skips upload. Any earlier release
deployment without a verified receipt stops for manual reconciliation, even if that run did not reach the Marketplace
step. The same applies when the linked job is no longer available. A changed publishing step name fails before upload
until the Action recognizes it.
Output: A marketplace-acceptance-<run>-<attempt> Actions artifact after a new upload. It records the tag, commit,
plugin, channel, signed ZIP artifact and SHA256, and notes artifact and SHA256. Remote effect: One multipart upload
to JetBrains Marketplace when no earlier acceptance exists, plus a GitHub Actions receipt artifact. The Action treats an
HTTP 2xx response as acceptance; it does not depend on undocumented response fields. Run the keyless release-creator
marketplace --mode stage check before this job and --mode verify after the version is public.
If the Action stops without a receipt, inspect the exact plugin, version, and channel in the Marketplace dashboard. If the update is pending review, wait until it is public and verify its member bytes against the original signed ZIP. Then rerun the tag workflow: the keyless stage check must skip Marketplace upload before GitHub Release publication. If Marketplace rejected the update, resolve the rejection and prepare a new release tag. A 404 alone does not authorize another upload of the same tag.
If an earlier release deployment blocks a retry, inspect its linked run and job. Only when its logs prove the
Marketplace upload step never started, mark that deployment inactive and delete it with the
GitHub deployment API, then rerun the tag
workflow:
gh api -X POST repos/OWNER/REPO/deployments/ID/statuses -f state=inactive
gh api -X DELETE repos/OWNER/REPO/deployments/IDIf the run or its logs have expired, do not delete the deployment based on absence from the public Marketplace download API: JetBrains may still be reviewing the upload. Ask JetBrains to confirm its status or cut a new release tag and version.
The Actions test suite executes each inline run: block in temporary Git repositories. Test a caller's
Towncrier template, release hooks, package build, and publisher against representative source data before changing its
live release workflow. Inspect the version, patch, changelog, notes, and built package.
This repository releases its own Actions with the same prepare and tag path. See Contributing for local checks and news fragments, and Changelog for released changes.