Reusable GitHub Actions workflows shared across the manic-systems organization. This lets us reuse the same workflow code with optional parameters to fine-grain their behaviour.
Note
Language-specific files are prefixed by language (rust-..., deno-...)
because GitHub doesn't allow subdirectories under .github/workflows/.
Language-neutral checks use descriptive names such as changelog-check.yml.
| Workflow | Use it for |
|---|---|
rust-checks.yml |
CI: nix flake check by default, plus opt-in cargo test/clippy/fmt. |
rust-build.yml |
Build a target with Nix on one runner. Dual-use: pass upload: true to attest provenance and ship the binary as a release asset. |
rust-release.yml |
Release bookkeeping (tag + create release; release notes + SHA256SUMS; optional crates.io publish). Invoked once per stage around the caller-driven build matrix. |
deno-fmt.yml |
Check Deno formatting of Markdown files without running language-specific CI. |
changelog-check.yml |
Require pull requests to update their changelog, with a label-based exemption. |
nix-checks.yml |
Language-neutral Nix flake checks and optional package or named-check builds. |
rust-audit.yml |
Cargo dependency advisories, licenses, bans, and sources; standalone or Nix-backed. |
actions-lint.yml |
Lint the caller's workflows with actionlint and shellcheck, and audit them with zizmor. |
auto-merge.yml |
Turn on auto-merge for Dependabot and manic-bot pull requests. |
pins-update.yml |
Weekly tack pin bumps in one pull request, opened by a GitHub App so CI runs on it. |
The caller owns any build or language-check matrix in plain YAML, as GitHub forbids array inputs to reusable workflows. Run documentation checks once, outside that matrix.
Runs actionlint, which also shellchecks every run: script, then points
zizmor's online audits at the caller's .github/. Both are pulled fresh from
nixpkgs-unstable on every run, so new audits start catching things the week they
ship. On a push or PR that doesn't touch .github/, the job checks that and
passes in a few seconds without installing anything, so it's safe to make a
required check. zizmor still runs when actionlint fails, and its findings land
as annotations right on the pull request. If it's too strict for a repo, loosen
it with zizmor-arguments, e.g. --min-severity medium.
jobs:
actions:
uses: manic-systems/workflows/.github/workflows/actions-lint.yml@v2Bumps your tack pins once a week and rolls everything that moved into a single
pins: update PR, which just gets refreshed if nobody merges it. It leaves
flake.lock alone, since the nix entry in
examples/dependabot.yml already has Dependabot
handling that. Dependabot can't touch Nix in private repos though, so set
flake: true there and this'll bump the lockfile too.
PRs opened with GITHUB_TOKEN don't trigger CI, so this opens them as a GitHub
App instead. It needs read and write on Contents and Pull requests, and its
client ID and private key go in the org's MANIC_BOT_CLIENT_ID variable and
MANIC_BOT_PRIVATE_KEY secret. Then just install it on whichever repos you want
bumped and copy in examples/pins-update.yml.
| Input | Default | Description |
|---|---|---|
client-id |
(required) | Client ID of the GitHub App. |
tack |
true |
Run tack update when .tack/pins.toml exists. |
tack-arguments |
(empty) | Extra tack update arguments, e.g. pin names, groups, or --exclude. |
tack-flake |
github:manic-systems/tack |
Flake reference tack is built from. |
flake |
false |
Also run nix flake update when flake.lock exists. |
flake-arguments |
(empty) | Extra nix flake update arguments, e.g. input names. |
branch |
pins-update |
Branch the update is force-pushed to. |
auto-merge |
false |
Turn on auto-merge so the PR lands once required checks pass. |
Turns on auto-merge for pull requests from Dependabot and manic-bot, so they
land by themselves once the branch's required checks pass. With no required
checks on the branch, GitHub merges them right away, so gate the branch on
something first. If the branch also wants a review, set approve: true, and
turn on "Allow GitHub Actions to create and approve pull requests" in the org's
Actions settings. Copy in examples/auto-merge.yml
and enable "Allow auto-merge" in the repo settings.
| Input | Default | Description |
|---|---|---|
authors |
dependabot[bot] manic-bot[bot] |
Space-separated users whose pull requests get auto-merge. |
merge-method |
rebase |
merge, rebase, or squash. Ignored with a merge queue. |
approve |
false |
Approve the pull request first, for branches that need a review. |
Runs deno fmt --check '**/*.md' in the caller's repository. The quoted glob
includes root and nested Markdown files without checking standalone TypeScript,
JSON, YAML, or other files. Deno can also format supported code fences inside
Markdown. Formatting errors fail the job; files are never rewritten.
The caller's deno.json / deno.jsonc formatting options and exclusions are
honored. Set working-directory to check a documentation subtree. To fix
formatting locally, run deno fmt '**/*.md' in that same directory. A directory
with no matching files fails rather than silently passing.
| Input | Default | Description |
|---|---|---|
os |
ubuntu-latest |
Runner image. |
working-directory |
. |
Directory containing Markdown files and optional Deno configuration. |
deno-version |
v2.x |
Deno version or semver range passed to setup-deno. |
Requires a pull request to add or modify CHANGELOG.md. The check compares the
PR head against its merge base with the PR base SHA, so unrelated changes on the
target branch cannot satisfy it. Deleting or renaming the changelog away does
not count. This enforces a file update, not a changelog schema or the contents
of an entry.
The job runs only on pull_request events and is skipped on pushes, dispatches,
and merge-group events. A PR carrying skip-changelog is exempt by default; set
skip-label to an empty string to disable exemptions. Callers should include
labeled and unlabeled PR activity types so changing a label reruns the
check. Both documentation checks need only contents: read, including for fork
PRs; no secrets or write permissions are required. Do not use
pull_request_target.
| Input | Default | Description |
|---|---|---|
os |
ubuntu-latest |
Runner image. |
changelog-path |
CHANGELOG.md |
Repository-relative literal file path (not a glob). |
skip-label |
skip-changelog |
Exemption label; empty disables the exemption. |
name: Documentation Checks
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
jobs:
markdown:
uses: manic-systems/workflows/.github/workflows/deno-fmt.yml@v2
changelog:
uses: manic-systems/workflows/.github/workflows/changelog-check.yml@v2
# with:
# changelog-path: docs/CHANGELOG.md
# skip-label: ""These jobs belong outside the Rust OS matrix: each check needs only one runner.
See examples/ci.yml for a combined caller.
Runs nix flake check --print-build-logs by default, with an optional package
build afterward. This is the language-neutral alternative to rust-checks.yml
for repositories that need only Nix. Call it once per runner in the caller's
platform matrix; see examples/nix.yml.
All command steps run in working-directory. Commands are shell scripts, so
quoting, pipelines, and compound commands work; errors fail the job. The Nix
installer is Cachix's action, matching the existing Rust workflows. For a
preconfigured runner, set install-nix: false; extra-nix-config applies only
when installation is enabled. No Rust toolchain is installed.
| Input | Default | Description |
|---|---|---|
os |
ubuntu-latest |
Runner image. |
working-directory |
. |
Directory containing the flake; used for every command step. |
install-nix |
true |
Install Nix before running commands. |
timeout-minutes |
60 |
Job timeout in minutes. |
extra-nix-config |
(empty) | Additional settings passed to the installer's extra_nix_config. |
cache |
false |
Restore the Nix store cache. See Nix store cache. |
cache-save |
false |
Also save it and purge this ref's older entries. Needs actions: write. |
cache-key |
nix-<repo> |
Key prefix. Jobs that share a store use the same key. |
cache-hash-files |
Nix, Cargo and Rust sources | Newline-separated globs, from the repo root, that key the cache. |
setup-command |
(empty) | Optional shell script run before checks and builds. |
flake-check |
true |
Run the flake-check command. |
flake-check-command |
nix flake check --print-build-logs |
Flake-check shell script. |
build |
false |
Run a package or named-check build after the flake check. |
build-command |
nix build --print-build-logs |
Build shell script. |
To run a named check (including a VM test) independently of the whole flake:
jobs:
vm-test:
uses: manic-systems/workflows/.github/workflows/nix-checks.yml@v2
with:
flake-check: false
build: true
build-command: nix build .#checks.x86_64-linux.eval --no-link -LThe workflow doesn't discover checks, build matrices, or set up KVM, so wire those up in the caller and pick a runner that can actually run the check. If the flake check fails, the build never starts.
Flip on cache and nix-checks.yml or rust-build.yml will pick up
/nix/store right where the last run left it, courtesy of
nix-community/cache-nix-action. Entries are keyed on cache-key, the runner's
OS and arch, the ref, and a hash of cache-hash-files, so bumping Cargo.lock
gets you a fresh entry while a docs-only push reuses the old one. Every PR gets
its own pr-<number> scope and the merge queue shares one. A job grabs the
newest entry from its own scope, and when there's nothing there yet, it borrows
the default branch's.
Put cache-save: true on whichever job builds the most, usually the package
build, and plain cache: true on the jobs downstream of it, like VM tests that
need the build. The saving job trims the store to 8 GiB before uploading and
purges older entries from its own scope, never anyone else's. Purging goes
through the Actions cache API, so grant that job actions: write.
jobs:
build:
permissions:
actions: write
contents: read
uses: manic-systems/workflows/.github/workflows/nix-checks.yml@v2
with:
flake-check: false
build: true
build-command: nix build .#ncro -L --no-link
cache: true
cache-save: true
vm-test:
needs: build
uses: manic-systems/workflows/.github/workflows/nix-checks.yml@v2
with:
flake-check: false
build: true
build-command: nix build .#checks.x86_64-linux.e2e -L --no-link
cache: trueRelease builds never touch the cache, since rust-build.yml skips it whenever
upload: true.
Runs cargo-deny against the caller's Cargo dependency graph and policy.
Provide the project's Cargo.toml and deny.toml; this workflow does not
invent an organization-wide license or dependency policy. By default it checks
all features and all four categories: advisories, licenses, bans, and sources.
Violations or advisory-database fetch errors fail the job.
The default standalone path uses SHA-pinned EmbarkStudios/cargo-deny-action
v2.1.1 (cargo-deny 0.20.2) with stable Rust. Its Docker image is x86_64-only, so
this workflow runs once on ubuntu-latest, outside the build matrix.
manifest-path is relative to the repository root. Put the policy alongside
that manifest, or select a custom policy with arguments, for example
--all-features --config policy/deny.toml.
Set install-nix: true to use the repository's dev shell instead of the Docker
action. The dev shell must provide Cargo and cargo-deny. Both Nix command steps
run in working-directory; standalone-only inputs do not affect these scripts.
The default Nix audit runs all categories with all features. For a reproducible
policy derivation followed by a fresh advisory audit, use:
jobs:
audit:
uses: manic-systems/workflows/.github/workflows/rust-audit.yml@v2
with:
install-nix: true
policy-command: nix build .#checks.x86_64-linux.cargo-deny --no-link -L
audit-command: nix develop --command cargo deny check advisoriesThe optional policy command runs first; a failure prevents the advisory command from running. A cached policy derivation is not a substitute for fetching fresh advisories, so keep the second step when splitting checks this way.
| Input | Default | Description |
|---|---|---|
rust-toolchain |
stable |
Standalone: Rust version installed inside the audit container. |
manifest-path |
Cargo.toml |
Standalone: repository-relative manifest path. |
arguments |
--all-features |
Standalone: global cargo-deny arguments, before check. |
command-arguments |
(empty) | Standalone: check arguments; empty checks all categories. |
install-nix |
false |
Select Nix-backed auditing and install Nix instead of using the Docker action. |
working-directory |
. |
Nix: directory containing the flake and audit configuration. |
policy-command |
(empty) | Nix: optional dependency-policy shell script before the audit. |
audit-command |
nix develop --command cargo deny --all-features check |
Nix: audit shell script. |
Both new workflows require only contents: read; they do not publish artifacts
or modify the repository. Command inputs are trusted caller-owned shell scripts,
not PR-provided text. examples/ci.yml includes a standalone
audit alongside Rust checks. Callers own all triggers; consider a scheduled
audit as well as PR/push checks, since advisories can appear without dependency
changes.
Runs nix flake check by default; toggle test / clippy / fmt for repos
that want additional cargo-driven checks. Cargo steps run inside the flake's dev
shell via nix develop --command, so CI gets whatever the shell ships, whether
that's nextest, taplo, or a pinned nightly. No dev shell? Set rust-toolchain
and the workflow installs that channel with rustup instead. A failing step
doesn't stop the rest, so fmt, clippy, and test all report on every run.
name: CI # rust-release.yml gates on this name
on:
push:
branches: [main]
pull_request:
jobs:
checks:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-26.04-arm, macos-latest]
uses: manic-systems/workflows/.github/workflows/rust-checks.yml@v2
with:
os: ${{ matrix.os }}
# flake-check is on by default; opt in to cargo checks as needed:
# test: true
# test-command: cargo nextest run --workspace --all-features
# clippy: true
# fmt: true| Input | Default | Description |
|---|---|---|
os |
(required) | Runner image. |
working-directory |
. |
Directory the cargo steps run in (the flake check stays at the repo root). |
install-nix |
true |
Install Nix (needed by nix flake check and the dev shell). |
timeout-minutes |
60 |
Job timeout in minutes. |
extra-nix-config |
(empty) | Additional settings passed to the installer's extra_nix_config. |
flake-check |
true |
Run the flake-check command. |
flake-check-command |
nix flake check --print-build-logs |
Shell script run when flake-check is true. |
rust-toolchain |
(empty) | Empty uses the dev shell. A channel such as stable installs it with rustup. |
rust-targets |
(empty) | Comma-separated extra rustup targets, e.g. wasm32v1-none. |
setup-command |
(empty) | Shell script run before the cargo steps, with Nix available. |
test |
false |
Run the test command. |
test-command |
cargo test --workspace --all-features |
|
clippy |
false |
Run the clippy command. |
clippy-command |
cargo clippy --workspace --all-targets --all-features -- -D warnings |
|
fmt |
false |
Run the fmt command. |
fmt-command |
cargo fmt --all --check |
Builds a project with Nix on one runner. Dual-use: upload: false (default) is
the release-build CI check; upload: true attests provenance and uploads the
binary as a release asset.
rust-build.yml declares no permissions of its own, so it inherits the
caller's token. upload: true requires the caller to grant contents /
id-token / attestations write; the CI usage above needs none.
| Input | Default | Description |
|---|---|---|
os |
(required) | Runner image. |
working-directory |
. |
Directory the build runs in; artifact-path is resolved relative to it. |
build-command |
nix build |
Command that builds the project (flake default package by default). |
install-nix |
true |
Install Nix before building. |
timeout-minutes |
60 |
Job timeout in minutes. |
extra-nix-config |
(empty) | Additional settings passed to the installer's extra_nix_config. |
cache |
false |
Restore the Nix store cache. See Nix store cache. |
cache-save |
false |
Also save it and purge this ref's older entries. Needs actions: write. |
cache-key |
nix-<repo> |
Key prefix. Jobs that share a store use the same key. |
cache-hash-files |
Nix, Cargo and Rust sources | Newline-separated globs, from the repo root, that key the cache. |
upload |
false |
Attest provenance and upload the binary to a release. |
version |
(required when upload: true) |
Release tag to upload to (pass needs.prepare.outputs.version). |
suffix |
(required when upload: true) |
Asset suffix; the asset is <asset-prefix>-<suffix>. |
binary-name |
repository name | Built binary name under the build output. |
asset-prefix |
repository name | Prefix for the asset filename. |
artifact-path |
result/bin/<binary-name> |
Path to the built binary (override for non-default layouts). |
The tag/create-release and notes/checksums bookkeeping, selected by a stage
input and invoked twice around the build matrix (a reusable workflow is a
single invocation, so it can't straddle the caller's matrix). The caller wires
prepare → build → finalize:
name: Tag and Release
on:
workflow_dispatch:
workflow_run:
workflows: [CI] # the CI workflow above
types: [completed]
branches: [main]
permissions:
contents: write # create the release / push the tag
id-token: write # build provenance attestations
attestations: write # build provenance attestations
jobs:
prepare:
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
uses: manic-systems/workflows/.github/workflows/rust-release.yml@v2
with:
stage: prepare
build:
needs: prepare
if: ${{ needs.prepare.result == 'success' && needs.prepare.outputs.skip != 'true' }}
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, suffix: linux-amd64 }
- { os: ubuntu-26.04-arm, suffix: linux-arm64 }
- { os: macos-latest, suffix: macos-arm64 }
uses: manic-systems/workflows/.github/workflows/rust-build.yml@v2
with:
upload: true
version: ${{ needs.prepare.outputs.version }}
os: ${{ matrix.os }}
suffix: ${{ matrix.suffix }}
finalize:
needs: [prepare, build]
if: ${{ needs.prepare.outputs.skip != 'true' && needs.build.result == 'success' }}
uses: manic-systems/workflows/.github/workflows/rust-release.yml@v2
with:
stage: finalize
version: ${{ needs.prepare.outputs.version }}See examples/ for the caller files with overrides annotated.
Pin this repository by commit and put the version in a trailing comment.
uses: manic-systems/workflows/.github/workflows/rust-checks.yml@<sha> # v2.0.0Then drop examples/dependabot.yml into
.github/dependabot.yml. Once a week Dependabot opens a single PR that bumps
every pin along with its comment. Third-party actions sit out a 7-day cooldown
first, while releases from this organization land on the next run. If you'd
rather float, @v2 works too.
Bump the VERSION file (bare semver) and push to main. The
Self-release workflow then tags vX.Y.Z and moves the floating vX tag to
the release commit, creates the GitHub release, and marks pre-release versions
(e.g. 1.2.0-rc.1) as prerelease. Releases can also be triggered manually via
workflow_dispatch at the current VERSION. A published vX.Y.Z tag never
moves, so if a release needs fixing, bump VERSION again.
| Input | Stage | Default | Description |
|---|---|---|---|
stage |
- | (required) | prepare, finalize, or publish. |
version |
finalize/publish | (required for finalize/publish) | The tag from the prepare stage. |
version-command |
prepare | nix run nixpkgs#fq -- -r '.workspace.package.version' Cargo.toml |
Command printing the bare version (no tag prefix) to stdout. |
tag-prefix |
prepare | v |
Prepended to the version to form the git tag. |
default-branch |
prepare | main |
Branch used to detect whether the version changed. |
install-nix |
prepare/publish | true |
Install Nix before reading the version or publishing. |
asset-prefix |
finalize | repository name | Prefix of the assets to checksum. Must match what build used. |
publish-command |
publish | cargo publish |
Command that publishes to crates.io (CARGO_REGISTRY_TOKEN is injected). |
publish-working-directory |
publish | . |
Directory the publish command runs in. |
publish-environment |
publish | (none) | GitHub Environment to run the publish job in; must match the crates.io Trusted Publisher config. |
rust-toolchain |
publish | stable |
Rust toolchain channel installed before publishing. |
Outputs (prepare stage): version (the tag), skip (true when the version is
unchanged).
The publish stage ships the crate to crates.io using Trusted Publishing.
Under this setup, GitHub's OIDC identity is exchanged for a short-lived registry
token via rust-lang/crates-io-auth-action, so no CARGO_REGISTRY_TOKEN
secret is stored.
One-time setup (per crate):
- Publish the crate manually once (
cargo publish). https://crates.io only lets you add a Trusted Publisher to an existing crate. - On
crates.iogo to the crate's Settings -> Trusted Publishing and add a GitHub publisher: owner, repository, the release workflow filename (e.g.release.yml), and optionally an environment name. If you set one, pass the same value aspublish-environment.
The publish job declares id-token: write itself, but a reusable workflow can't
grant a permission the caller withheld. The caller's release workflow must also
grant id-token: write (the example already does). Wire it after finalize:
publish:
needs: [prepare, finalize]
if: ${{ needs.prepare.outputs.skip != 'true' && needs.finalize.result == 'success' }}
uses: manic-systems/workflows/.github/workflows/rust-release.yml@v2
with:
stage: publish
version: ${{ needs.prepare.outputs.version }}
# publish-environment: release # set if the Trusted Publisher config uses one
# publish-command: cargo publish -p my-crate # e.g. a specific workspace member
# publish-command: nix develop --command cargo publish --workspace # crates that need the dev shell- Every
*-commandinput is a bash script run withbash -euo pipefail -c, so quoting, pipelines, and&&chains behave as they would in a terminal. They run in the consuming repo's job. Treat them as trusted and only set them from workflows you control. - Under
workflow_run,prepareonly releases a commit that CI tested on a push and that's still the branch head. CI runs for pull requests are skipped, and so is a run that a newer push overtook. That newer commit's own CI run handles the release. stageis validated. A value other thanprepare,finalize, orpublishfails rather than succeeding with every release job skipped.- Build provenance attestations are free for public repositories. Verify an
asset with
gh attestation verify <file> --repo <owner>/<repo>. - Every third-party action is pinned to the commit of a release tag, with that exact tag in a trailing comment.
- crates.io Trusted Publishing currently supports GitHub Actions only, and the
publish job runs on
ubuntu-latest. Publishing is host-independent. - If you override
binary-name/asset-prefix, set them on bothbuildandfinalizeso the checksum step finds the right assets. - Builds are native per runner. There is no cross-compilation.
linux-arm64uses GitHub's nativeubuntu-26.04-armrunner. finalizeonly runs when everybuildleg succeeds (fail-fast: falselets the others finish, but a failed target blocks notes/checksums).