Skip to content
manic-systemsPublic

About

Reusable GitHub Actions workflows shared across the manic-systems organization

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

47 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

workflows

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.


Actions lint - actions-lint.yml

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@v2

Pins update - pins-update.yml

Bumps 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.

Inputs

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.

Auto-merge - auto-merge.yml

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.

Inputs

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.

Markdown formatting - deno-fmt.yml

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.

Inputs

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.

Changelog check - changelog-check.yml

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.

Inputs

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.

Caller example

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.


Nix checks - nix-checks.yml

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.

Inputs

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 -L

The 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.

Nix store cache

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: true

Release builds never touch the cache, since rust-build.yml skips it whenever upload: true.

Dependency audit - rust-audit.yml

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 advisories

The 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.

Inputs

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.


Check - rust-checks.yml

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

Inputs

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

Build - rust-build.yml

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.

Inputs

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).

Release: rust-release.yml

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.

Pinning

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.0

Then 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.

Releasing this repo

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.

Inputs

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).

Publishing to crates.io (Trusted Publishing)

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):

  1. Publish the crate manually once (cargo publish). https://crates.io only lets you add a Trusted Publisher to an existing crate.
  2. On crates.io go 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 as publish-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

Notes

  • Every *-command input is a bash script run with bash -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, prepare only 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.
  • stage is validated. A value other than prepare, finalize, or publish fails 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 both build and finalize so the checksum step finds the right assets.
  • Builds are native per runner. There is no cross-compilation. linux-arm64 uses GitHub's native ubuntu-26.04-arm runner.
  • finalize only runs when every build leg succeeds (fail-fast: false lets the others finish, but a failed target blocks notes/checksums).

About

Reusable GitHub Actions workflows shared across the manic-systems organization

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors