| name | github-actions-conventions |
| description | GitHub Actions / CI conventions (canonical quality/build/chart/release |
GitHub Actions conventions
Apply these when the repo is hosted on GitHub.
YAML style (workflows and .github/ config)
- No leading
--- document marker at the top of workflow or other
.github/ config YAML files. Start directly with the first key.
- Write the trigger key as bare
on:, never quoted "on":. (Modern
parsers and actionlint handle the YAML truthiness of on fine.)
- Block style only, consistent with
yaml-conventions.
Step naming
- Every step has a
name: — mandatory for uses: steps, and expected on
run: steps too. A nameless action step reads as a bare SHA in the UI.
- Step names start with a lowercase letter (not sentence-cased):
name: set up the Rust toolchain, not name: Set up the Rust toolchain. Proper nouns
inside the name keep their capitals (Rust, GHCR, GitHub).
- Keep step names static — never interpolate a
${{ }} expression into a
name:. A conditional/templated label adds noise for no real benefit; pick
one fixed name (name: build the image, not
name: build${{ inputs.push && ' and push' || '' }}).
Pin actions by SHA
The canonical workflow set
Create exactly these workflows, conditioned on what the repo contains. Give
each least-privilege permissions: (default contents: read; widen only in
the job that needs it).
quality — always. Runs pre-commit run --all-files and the test
suite, on push to the default branch and on every pull request. Because the
language: system hooks shell out to real binaries, the job must install
every tool the hooks need (toolchain + helm, helm-docs, hadolint,
actionlint, …) before running pre-commit. This is the single quality
gate — do not scatter fmt/lint/test across ad-hoc workflows.
build — when a Dockerfile exists. Builds the image, and pushes
only when explicitly asked: triggered by workflow_dispatch (a push
boolean input) or invoked via workflow_call with push: true. On a plain
push/PR it builds without pushing (validation only). Expose push and
version as workflow_call/workflow_dispatch inputs. Multi-arch Rust:
always a static matrix over both architectures — amd64 on
ubuntu-24.04 and arm64 on ubuntu-24.04-arm — each on its own native
runner. Never QEMU-emulate a Rust build, and never drop an architecture
from the matrix on PRs (validate both). When pushing, each arch job
build/pushes by digest and a final manifest job assembles the
multi-arch manifest (that job runs only when pushing). Derive image
tags and labels with docker/metadata-action — labels on each per-arch
build, tags in the manifest job (consumed from DOCKER_METADATA_OUTPUT_JSON
by docker buildx imagetools create). Never hand-roll tag strings.
chart — when a Helm chart exists. Publishes the chart as an OCI
artifact to GHCR (helm push → oci://ghcr.io/<owner>/charts). The chart
has its own release lifecycle, decoupled from the app: trigger it on a
dedicated tag namespace chart-* (plus for manual
publishes), never on PR (PR validation is inside ).
Derive the chart version from the tag and pass it to
; leave to (the app
image the chart targets evolves independently of the chart's own version).
The workflow does publish the chart.
Path filters — don't trigger for nothing
- A workflow triggered on
push/pull_request must carry a paths: (or
paths-ignore:) filter so it only runs when files that actually affect it
change. A multi-arch image build must not fire on a docs-only or
chart-only change; scope it to its real inputs (e.g. src/**, Cargo.toml,
Cargo.lock, Dockerfile, .dockerignore, and the workflow file itself).
- Exception — the
quality workflow (pre-commit + tests) is never
path-filtered. It is the universal gate and must run on every push and pull
request, whatever changed.
- Tag-triggered workflows (
chart on chart-*, release on v*) and
workflow_dispatch / workflow_call take no paths — path filters do
not apply to those events.
Concurrency — one run per workflow per ref
Every workflow carries a top-level concurrency: block keyed on the ref, so
two runs of the same workflow never overlap on the same branch or tag:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: true, always — no exceptions, publishing workflows
included. A run for a superseded commit is dead weight; kill it and free the
runner. Do not reach for false on release/chart to protect a push
mid-flight: registries are the place to make a partial publish safe (immutable
tags, digest-addressed pushes, a re-run of the same tag), not the concurrency
block. Never write cancel-in-progress: false, and never make it conditional
on the event.
- In a reusable (
workflow_call) workflow, hardcode the workflow name in the
group instead of ${{ github.workflow }}. In a called run that expression
resolves to the caller, so build would land in the same group as
release and cancel the very job waiting on it. Write
group: build-${{ github.ref }}.
- Key on
github.ref, not github.head_ref — the latter is empty outside
pull_request events and would collapse every push into one shared group.
Cache deliberately, and pragmatically
Cache what is expensive to recompute, not what is cheap to re-download.
The crates.io / registry download is fast; restoring a large dependency cache
can be slower than a clean fetch, and a stale cache is worse than none.
- Keep: the Docker layer cache, scoped per architecture
(
type=gha,scope=<arch>) so the two arch runners never clobber each other;
and the compiled-dependency cache (cargo-chef in the Dockerfile,
Swatinem/rust-cache for non-Docker Rust jobs) — these cache CPU work,
not downloads.
- Skip: caches wrapped around a fast download just because you can. Measure
before adding one.
Never:
- Never start a workflow file with
---, and never quote "on".
- Never use a bare branch/tag action ref — pin the SHA (with a tag comment) and
let Dependabot bump it.
- Never push an image or publish a chart on a pull request; pushing happens only
via
workflow_dispatch or the release orchestration.
- Never cut a release by editing a version field — derive the version from the
git tag at build time.
- Never QEMU-emulate a Rust multi-arch build when native runners exist, and
never share one unscoped build cache across architectures.
- Never split the quality gate:
pre-commit + tests live in the single
quality workflow.
- Never ship a workflow without a
concurrency: group, and never set
cancel-in-progress to anything but true — not false, not an expression,
not even on release/chart.