Reusable GitHub Actions workflows for public Rust crates.
Today this repo provides one all-in-one CI workflow,
rust-ci.yml. It is designed to grow: future
workflows (release-only, benchmark, cross-compile matrices) can be added
alongside it without breaking callers.
The Cargo.toml and src/ at the repo root are a tiny fixture crate, not a
published library: repo CI smoke-tests rust-ci.yml against it on every pull
request.
Add a thin caller workflow to your repo. The caller owns the triggers; the
reusable workflow owns the jobs. A full example that reproduces a single-crate
public CI (no_std build, fuzzing, miri, tag-gated crates.io publish) lives in
examples/ci.yml.
# .github/workflows/ci.ymlname: CIon:
pull_request:
push:
branches: [main]tags: ['v*']# Concurrency is caller-owned; the reusable workflow defines none of its own.concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}cancel-in-progress: truejobs:
ci:
uses: luminartech/rust_workflow/.github/workflows/rust-ci.yml@v1permissions:
contents: writesecrets:
cargo-registry-token: ${{ secrets.CARGO_CI_TOKEN }}with:
publish-repository: my-org/my_crateEvery stage runs by default. Disable the ones you do not need with the
run-* / publish-crate toggles, e.g. a pure-std crate turns off the
bare-metal build with run-no-std: false.
All test jobs downscope themselves to contents: read; only the release path
uses the caller's broader grant. contents: write is needed to create GitHub
releases (drop it, along with the registry token, if you disable publishing);
release-plz additionally needs pull-requests: write. The reusable workflow
never requests write scopes itself, so callers running with a read-only token
(fork PRs, for example) still pass workflow validation.
For branch protection, require the individual checks that must run on every
PR (for example ci / Lint & Format, ci / Unit Tests). There is
deliberately no aggregate "CI OK" job: GitHub treats a skipped required check
as satisfied, so an aggregate that tolerates skips would stay green even when
a job was skipped unexpectedly; per-job required checks keep each context
visible on the PR. Repos using a merge queue add merge_group: to the
caller's triggers; merge-queue runs take the comprehensive path as the final
pre-merge gate.
Most stages are gated only by their toggle. Two behaviors are event-driven:
- Comprehensive path. Fuzz targets run for
fuzz-max-timeseconds normally andfuzz-max-time-comprehensiveseconds on comprehensive runs; miri only runs on comprehensive runs. The comprehensive path auto-enables onschedule, onmerge_groupevents, on pushes todefault-branch, and on tag pushes. A manual dispatch can force it withcomprehensive-tests: true. Setmiri-comprehensive-only: falseto run miri on pull requests too, which is worth it for crates where miri is fast. - Semver + publish. Semver checks run on pull requests and
v*tag pushes. The publish dry run happens on pull requests only, catching unpublishable changes before merge without needing a registry token. The release job runs only onv*tag pushes, subject topublish-crateand the optionalpublish-repositoryguard.
Set use-release-plz: true to hand versioning, changelogs, tags, and
publishing to release-plz
instead of the tag-gated release job (which is disabled while the flag is on).
On every default-branch push, a release PR is created or updated with
version bumps and changelog entries derived from conventional commits; merging
it publishes to crates.io, pushes the version tags, and creates the GitHub
releases. Pushes where everything is already published are a no-op, and the
publish half only runs after the rest of CI has passed.
Requirements for consumers:
- Grant the caller job
permissions: contents: write, pull-requests: write. - Pass a token so the release PR triggers CI (PRs opened with the default
github.tokendo not). Preferred:release-plz-app-id+release-plz-app-private-key, from which each job mints a short-lived, scoped GitHub App installation token. Alternative: arelease-plz-tokenPAT. - Configure per-repo behavior with a
release-plz.tomlif needed. - The publish step still runs in the
publish-environmentenvironment and honors thepublish-repositoryguard; environment protection rules will gate every default-branch release run.
| Input | Stage |
|---|---|
run-pre-commit | pre-commit hooks |
run-lint | rustfmt + clippy |
run-build | release build |
run-no-std | bare-metal no_std build + no_std clippy variants |
run-unit-tests | unit/integration tests with coverage |
run-property-tests | property tests |
run-fuzz-tests | cargo-fuzz targets |
run-miri-tests | miri (comprehensive runs only unless miri-comprehensive-only: false) |
run-msrv | MSRV build |
run-semver-checks | cargo-semver-checks (PRs + tags only) |
run-security | cargo-audit + cargo-deny |
use-audit-check | default false: cargo-audit as a check run; the caller must grant checks: write |
run-docs | rustdoc with warnings denied + doctests |
run-publish-dry-run | cargo publish --dry-run (PRs only) |
publish-crate | crates.io publish + GitHub release (tags only) |
| Input | Type | Default | Notes |
|---|---|---|---|
comprehensive-tests | boolean | false | Force the comprehensive path |
default-branch | string | main | Branch whose pushes are comprehensive |
| Input | Type | Default |
|---|---|---|
toolchain | string | stable |
nightly-toolchain | string | nightly |
msrv | string | '' |
no-std-target | string | thumbv6m-none-eabi |
alloc-feature | string | alloc |
msrv defaults to empty, which reads rust-version from Cargo.toml so the
workflow cannot drift from what the manifest promises; set it only to test a
different toolchain than the manifest declares.
Set alloc-feature: '' for crates with no alloc feature (unconditionally
no-alloc); the alloc build and lint variants are skipped.
The no_std build is a canary, not a deployment target. Building for a *-none
target (where std does not exist) is the only way to prove that nothing in the
dependency graph pulls std back in; a plain --no-default-features build on a
hosted target cannot catch that. thumbv6m-none-eabi is the strictest common
denominator (32-bit, no atomic compare-and-swap), so code that builds there
builds essentially everywhere, embedded or desktop.
| Input | Type | Default | Notes |
|---|---|---|---|
package | string | '' | Crate for semver-checks + publish (empty = workspace) |
feature-flags | string | --all-features | Injected into build/test/clippy/docs/msrv |
clippy-args | string | -D warnings -D clippy::pedantic | Passed after clippy -- |
unit-test-filter | string | not test(prop_) | nextest filter for unit tests |
property-test-filter | string | test(prop_) | nextest filter for property tests |
property-test-threads | number | 4 | Property-test thread count |
fuzz-targets | string | '' | Space-separated targets (empty = cargo fuzz list) |
fuzz-max-time | number | 6 | Per-target fuzz seconds, normal run |
fuzz-max-time-comprehensive | number | 60 | Per-target fuzz seconds, comprehensive run |
miri-args | string | '' | Extra args for cargo miri test (e.g. -p my_crate) |
nextest-profile | string | '' | nextest profile for test jobs (empty = default) |
| Input | Type | Default | Notes |
|---|---|---|---|
upload-coverage | boolean | true | Upload the LCOV report |
upload-codecov | boolean | false | Upload coverage + test results to Codecov (test results need nextest-profile with a junit config) |
coverage-artifact-name | string | coverage-lcov | |
coverage-retention-days | number | 7 | |
upload-fuzz-artifacts | boolean | true | Upload corpus/crashes on fuzz failure |
fuzz-artifact-retention-days | number | 30 | |
upload-docs | boolean | false | Upload generated rustdoc |
docs-retention-days | number | 7 |
| Input | Type | Default | Notes |
|---|---|---|---|
publish-environment | string | crates-io | GitHub environment gating publish |
publish-repository | string | '' | Restrict publish to this owner/repo |
generate-release-notes | boolean | true | Auto-generate GitHub release notes |
use-release-plz | boolean | false | Hand releases to release-plz |
| Secret | Required | Notes |
|---|---|---|
cargo-registry-token | no | Token for cargo publish (needed only when publishing) |
release-plz-token | no | PAT for release-plz (falls back to github.token) |
release-plz-app-id | no | GitHub App ID; mints a short-lived token, preferred over a PAT |
release-plz-app-private-key | no | Private key of the release-plz GitHub App |
codecov-token | no | Codecov upload token (needed only with upload-codecov) |
The runner (ubuntu-latest) and the common environment (CARGO_TERM_COLOR,
RUST_BACKTRACE, CARGO_INCREMENTAL) are not configurable. Open an issue if
a consumer needs one of these opened up.
Concurrency is the caller's job: a concurrency block in a called workflow is
evaluated in the caller's run context, computes the same group as the caller's
own block, and GitHub cancels the run as a parent/child deadlock. Define the
policy in your caller as shown in the usage example.