A GitHub Action that automatically bumps the version of a Rust crate according to semantic versioning rules and publishes it to crates.io.
Quick recap of semver rules:
- Bump
majorif you make incompatible API changes. - Bump
minorif you add functionality in a backwards-compatible manner. - Bump
patchif you make backwards-compatible bug fixes.
- 🔄 Automatic semantic version bumping (patch, minor, major)
- 📦 Publishes to crates.io
- 🏷️ Creates git tags automatically
- ✅ Waits for configurable status checks to pass
- 🧪 Dry run mode for testing
- 🔒 Registry-confirmed rollback on publish failure, as a force-push or a revert (
rollback_mode) - 🌿 Branch-aware publishing (only publishes from main by default)
- 🛠️ Built-in Rust toolchain management
- 📋 Comprehensive validation and testing
The git tag and the crates.io artifact are created by one job from one
commit, so they cannot diverge. A hand-run cargo publish followed by a
later tag is how a release ends up pointing at code that never shipped — the
published .crate records the commit it was packaged from
(.cargo_vcs_info.json), and nothing ties a manually placed tag to it.
Here, push runs before publish (a branch that moved fails the run before
anything irreversible), and rollback consults the registry before touching
history.
- You need to manually create a GitHub release from each tag.
- No automatic release notes management.
- Only supports crates.io as the registry.
- Only supports packages with a single crate in the root directory.
- Little to no built-in CI checks: this keeps costs down and avoids complexity, but you should have your own CI checks in place.
- SECURITY: requires you to add a PAT to your repository secrets with write access to the repo.
name: Publish
on:
workflow_dispatch:
inputs:
bump_type:
description: "Version bump type"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
dry_run:
description: "Dry run mode"
required: false
default: true
type: boolean
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Publish crate
uses: tsnl/semver-bump-and-cargo-publish@v1
with:
bump_type: ${{ github.event.inputs.bump_type }}
dry_run: ${{ github.event.inputs.dry_run }}
cargo_registry_token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
pat_token: ${{ secrets.PAT_TOKEN }}name: Publish
on:
workflow_dispatch:
inputs:
bump_type:
description: "Version bump type"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
dry_run:
description: "Dry run mode"
required: false
default: true
type: boolean
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Publish crate
uses: tsnl/semver-bump-and-cargo-publish@v1
with:
bump_type: ${{ github.event.inputs.bump_type }}
dry_run: ${{ github.event.inputs.dry_run }}
cargo_registry_token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
pat_token: ${{ secrets.PAT_TOKEN }}
wait_for_checks: "CI / Test, CI / Lint, CI / Check Formatting"
check_wait_interval: "60"
check_timeout_count: "20"
rust_toolchain: "stable"
git_user_email: "[email protected]"
git_user_name: "Release Bot"| Input | Description | Required | Default |
|---|---|---|---|
branch |
Branch to publish from | Yes | main |
bump_type |
Version bump type (patch, minor, major) |
Yes | patch |
dry_run |
Skip automated commit, push, and publish | No | true |
cargo_registry_token |
Cargo registry token for crates.io | Yes | - |
pat_token |
Personal access token for repository access | Yes | - |
rollback_mode |
How a confirmed-failed publish is undone: force-push (clean history; PAT must be allowed to force-push the branch) or revert (append-only; works on protected branches) |
No | force-push |
rust_toolchain |
Rust toolchain version to use | No | stable |
git_user_email |
Git user email for commits | No | [email protected] |
git_user_name |
Git user name for commits | No | GitHub Actions |
wait_for_checks |
Comma-separated list of status check contexts to wait for | No | "" |
check_wait_interval |
Time to wait between status check polling (seconds) | No | 60 |
check_timeout_count |
Number of polling attempts before timeout | No | 20 |
| Output | Description |
|---|---|
package_name |
Name of the published package |
old_version |
Previous version before bump |
new_version |
New version after bump |
tag_name |
Git tag name created |
published |
Whether the package was published to crates.io |
You need to set up the following secrets in your repository:
-
CARGO_REGISTRY_TOKEN: Your crates.io API token- Get this from crates.io/me
- Go to Account Settings → API Tokens → New Token
-
PAT_TOKEN: GitHub Personal Access Token- Go to GitHub Settings → Developer settings → Personal access tokens
- Create a fine-grained token with:
- Repository access: only the one repository this action runs in.
- Repository permissions: Contents: Read and write (Metadata: Read is added automatically). Nothing else.
- This is what pushes the bump commit and tag back to the repository.
With the default
rollback_mode: force-pushit must also be allowed to force-push the publish branch — see Security and required permissions.
The action itself needs no elevated GITHUB_TOKEN permissions — every
privileged operation (pushing the bump commit and tag, reading check runs)
goes through pat_token, so the default token permissions suffice.
Grant contents: write only if a later step in your workflow needs it —
for example creating a GitHub Release with the default token, as
examples/publish.yml does.
The action can wait for specific GitHub status checks to pass before proceeding with the publish. This is useful for ensuring CI tests pass before publishing.
Checks are awaited on the checked-out tip of branch — the commit that
will actually be bumped and published — not on the commit that triggered the
workflow. The two differ when the workflow is dispatched or push-triggered
from another ref. If a named check never registers on that commit and no
workflow is running that could still produce it, the action fails after three
consecutive not-found polls instead of waiting out the full timeout.
To find the correct status check names for the wait_for_checks input:
- Go to a recent commit in your repository.
- Click on the status checks (✅ or ❌ icon).
- The names shown are what you should use in
wait_for_checks
For GitHub Actions workflows, the format is typically:
{workflow_name} / {job_name}- Example:
test,lint,Check Formatting, etc.
wait_for_checks: "test, lint, Check Formatting"- Validation: Validates inputs and required tokens
- Status Checks: Waits for specified status checks to pass (if configured)
- Setup: Installs Rust toolchain and required tools
- Version Bump: Bumps version in
Cargo.tomland updatesCargo.lock - Build: Builds the crate (
cargo build --release). No tests, clippy, or formatting run here — gate the publish on your own CI viawait_for_checks - Git Operations: Commits changes and creates git tag
- Dry Run Publish: Tests the publish process
- Push: Pushes changes to repository (if not dry run)
- Publish: Publishes to crates.io (if not dry run and on main branch)
- Rollback: On publish failure, first confirms with crates.io that the version really is absent, then rolls back. A publish that failed only on the response (version actually live) keeps its commit and tag; an unreachable registry keeps them too and fails with instructions, since a stale tag is recoverable and a rolled-back live version is not
- Main branch: Full publish to crates.io
- Other branches: Version bump and tag creation only (no crates.io publish)
- Dry run: No commits, pushes, or publishes (testing only)
Every failure path is designed so the remote (git origin, crates.io) is the truth, and the unrecoverable direction is never chosen on a guess:
| Failure | What happens |
|---|---|
| Required check red or missing | Aborts before any bump — nothing to undo |
| Tag already exists on origin | Aborts before any bump (the remote is asked, not the local clone) |
| Origin unreachable for the tag check | Aborts — refuses to release blind |
cargo publish --dry-run fails |
Aborts with the bump committed locally only — nothing pushed |
| Branch moved between checkout and push | Push fails non-fast-forward, run dies before publish |
| Publish fails, crates.io confirms version absent (404) | Rollback per rollback_mode: tag deleted, bump undone |
| Publish fails, but the version is live on crates.io | Commit and tag are kept; published output is true (cargo's exit code was wrong, the release happened) |
| Publish fails, crates.io unreachable | Commit and tag are kept; run fails with instructions. A stale tag is recoverable (cargo publish again from it); rolling back a live version is not |
| Force-push rollback rejected | Run fails telling you whether to grant force-push or switch to rollback_mode: revert |
pat_tokenneedscontents: writeon the repository (pushing the bump commit and tag). Use a fine-grained PAT scoped to the one repository.- With
rollback_mode: force-push(the default), the PAT must additionally be allowed to force-push the publish branch — i.e. the branch is unprotected for it, or it has a branch-protection bypass. - With
rollback_mode: revert, no force-push permission is needed; rollback is an ordinary push.
- With
cargo_registry_tokenshould be a crates.io token scoped to publish-update for the one crate, not a global token.- Third-party actions used internally are pinned by commit SHA.
name: Monthly Release
on:
schedule:
- cron: '0 0 1 * *' # First day of every month
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- uses: tsnl/semver-bump-and-cargo-publish@v1
with:
bump_type: "minor"
dry_run: "false"
cargo_registry_token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
pat_token: ${{ secrets.PAT_TOKEN }}name: Release
on:
workflow_dispatch:
inputs:
version:
type: choice
options: [patch, minor, major]
default: patch
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- uses: tsnl/semver-bump-and-cargo-publish@v1
with:
bump_type: ${{ github.event.inputs.version }}
dry_run: "false"
cargo_registry_token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
pat_token: ${{ secrets.PAT_TOKEN }}
wait_for_checks: "CI / test, CI / lint, CI / check-formatting"
rust_toolchain: "stable"Contributions are welcome! Please feel free to submit a Pull Request.
If you're having trouble with the wait_for_checks feature:
-
Check the exact names: Go to a recent commit in your repository, click on the status checks, and copy the exact names shown.
-
Format for GitHub Actions: Use the format
workflow_name / job_nameexactly as shown in the GitHub UI.- Example:
CI / Test,CI / Lint,CI / Check Formatting
- Example:
-
Case sensitivity: Names are case-sensitive and must match exactly.
-
External CI systems: For non-GitHub Actions (Travis CI, CircleCI, etc.), use the context name as shown in the status API.
-
Debugging: If a check isn't found, the action will show helpful error messages and suggestions.
- "Status check not found": Verify the exact name format and spelling
- "Timeout waiting for status check": Check if the workflow is actually running or if it failed
- "Invalid bump_type": Must be one of:
patch,minor,major - Token issues: Ensure both
CARGO_REGISTRY_TOKENandPAT_TOKENsecrets are set
If you encounter issues:
- Check the action logs for detailed error messages
- Verify your repository secrets are configured correctly
- Test with
dry_run: truefirst - Open an issue with the full error log and your workflow configuration
This project is licensed under the MIT License - see the LICENSE file for details.