Common files for all repos.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-08 21:01:41 +02:00
.forgejo/workflows feat(check): guard against consumers shadowing common recipes 2026-08-08 15:00:49 -04:00
.gitignore feat: Add .gitignore 2026-06-26 15:12:41 -04:00
CLAUDE.md Merge branch 'main' into feat/GOV-46-configurable-pre-commit 2026-08-08 03:27:46 +02:00
common.just feat(check): guard against consumers shadowing common recipes 2026-08-08 15:00:49 -04:00
README.md feat(check): guard against consumers shadowing common recipes 2026-08-08 15:00:49 -04:00

common

Shared task runner and CI scaffolding for psa-systems Rust services. Consumers vendor this repo as the common git submodule and import common.just from their own justfile.

What lives here

  • common.just - the shared just recipes (hooks, checks, dev container, docker build, cleanup, release).
  • .forgejo/workflows/ - shared CI workflow(s).
  • .gitignore - the shared ignore set.

Consuming it

Add the submodule and import the recipes:

git submodule add https://dev.a8n.run/psa-systems/common.git common

In the consumer's justfile:

app := "my-service"

import 'common/common.just'

app names the service and drives image tags ({{app}}:check, {{app}}:local) and the cargo cache volume names. After cloning a consumer repo, run git submodule update --init so the import resolves. To pull a newer common, update the submodule and commit the new gitlink.

A recipe or variable defined in the consumer justfile overrides the imported one of the same name (common.just sets allow-duplicate-variables; consumers that override recipes add set allow-duplicate-recipes). Prefer configuring behavior through the variables below over shadowing whole recipes, so the shared implementation stays the single source of truth.

Define your own default recipe, first

common.just has a default recipe that runs just --list, but a consumer does not inherit it. just resolves the no-argument recipe by source order in the root justfile and an imported recipe is never eligible, so bare just runs whichever recipe the consumer defines first, and a consumer that defines none errors with Justfile contains no default recipe. Every consumer therefore defines default itself:

set allow-duplicate-recipes := true

import 'common/common.just'

# List available recipes. Keep FIRST: just picks the default recipe by source order.
default:
    @just --list

Keep it first. A recipe inserted above it silently steals the default, which is how repos ended up running an expensive check or pre-commit suite (or a never-exiting cargo watch) on a bare just. set allow-duplicate-recipes := true is required, because redefining default collides with the imported one.

Pre-commit and dev shape (compose vs no-compose)

common.just serves two repo shapes. Compose-backed services (the default) run their checks in the compose.dev.yml app service. No-compose CLI tools have no compose.dev.yml and run the same checks in a bare docker run. Select the shape with variables; dev-clean auto-adapts by testing for compose.dev.yml, so it needs no flag.

Variable Default Purpose
pre_commit_mode "compose" "compose" runs pre-commit checks in the compose.dev.yml service named by compose_service; "docker" runs them in dev_image via docker run with per-user named cargo caches (no compose stack needed).
dev_image "" Builder image for pre_commit_mode == "docker". Required in that mode, unused in compose mode.
compose_service "app" The compose.dev.yml service the compose-mode checks run in. Override when the dev service is named after the app rather than app (e.g. roci's roci).
pre_commit_prepare "" Name of a consumer-defined recipe run on the HOST before the containerized steps; empty means none. This is the seam for work that cannot happen in the check container: generating an asset the cargo steps then embed (build-css), or pre-creating a mount point so the daemon does not materialize it root-owned (ensure-target).
clippy_args "--all-targets --all-features -- --deny warnings" Arguments to cargo clippy. A feature-gated crate overrides this so clippy sees the same cfg CI does, e.g. "--all-targets --features server -- -D warnings".
compile_step "build" "build" or "check". Mirrors the reusable CI workflow's input of the same name: "check" only typechecks, for a crate whose targets are too expensive to link locally. Any other value fails before a container starts.
compile_args "--all-targets" Arguments to that compile step, e.g. "--features server --no-default-features".
wasm_check "false" Set to "true" to add a second compile pass against wasm_target, for a crate that also ships to wasm.
wasm_target "wasm32-unknown-unknown" Target triple for that pass.
wasm_check_args "" Feature flags for that pass. Empty is valid and means none.
test_args "--bins" Arguments to cargo test in the pre-commit checks. Override to "" (full suite) or "--all-targets" in a workspace with library or integration tests, so they are not silently skipped. The reusable CI workflow takes the same knob as a test_args workflow_call input with the same default; set both to the same value so local and CI runs agree on what "tests" means.
binary_name app Name of the binary file the binary stage exports; build-docker-export reports dist/{{binary_name}}. Override when the binary name diverges from app.
dockerfile "oci-build/Dockerfile" Path to the Dockerfile the check-docker / build-docker / build-docker-export recipes build. Override when the Dockerfile lives elsewhere or is suffixed (e.g. a Dioxus web build sets dockerfile := "ci-build/Dockerfile.web").
dev_extra_volumes "" Whitespace-separated list of extra named volumes dev-clean removes beyond the cargo caches, given as base names WITHOUT the -$USER suffix (dev-clean appends it). A compose service with its own data volume sets e.g. dev_extra_volumes := "dev-vervain-server-data" so just dev-clean does not leave stale database state behind. Volumes that do not exist are skipped.

A no-compose CLI consumer opts in with:

app := "my-cli"
pre_commit_mode := "docker"
dev_image := "ghcr.io/niceguyit/rust-builder-glibc:v1.0.1-rust1.94-trixie"
test_args := ""
binary_name := "my-cli"

import 'common/common.just'

Recipes: pre-commit (dispatcher, the one to run), pre-commit-compose, and pre-commit-docker (the two variants it calls), plus check-tree-ownership. common's dev-local* / dev-logs / ensure-env are compose-only and are inapplicable (harmless if left unused) in a no-compose repo.

pre-commit-compose builds the compose_service before running the checks, because docker compose run builds only when the image is absent and would otherwise reuse a stale image after a dev Dockerfile change; cached layers make the build a no-op. Both variants end with check-tree-ownership, which fails the commit when a containerized step left any path in the working tree not owned by the host user (the DEV-371 regression guard mandated by governance PRE_COMMIT.md). It is also runnable on its own, e.g. in CI after a clean-clone just pre-commit. Consumers get both from common and should not re-implement either locally.

Do not redefine pre-commit (enforced by check-justfile)

Configure the hook through the variables above; never shadow pre-commit, pre-commit-compose, or pre-commit-docker with a local copy. The step list is the shared contract: it is what keeps the hook and CI in step, and what carries a new guard to every repo at once. A repo that redefines pre-commit silently stops receiving changes to it, which is exactly what happened with check-tree-ownership: it landed in both shared variants and reached only the repos that had not forked the recipe, so it had to be hand-ported into five justfiles one at a time.

That is no longer an assertion consumers can ignore. check-justfile reads the root justfile, extracts its recipe names, intersects them with a protected list, and exits 1 naming every offender:

FAIL: justfile redefines pre-commit, check-tree-ownership, which must come from common/common.just

It runs as a dependency of both pre-commit-compose and pre-commit-docker, and as a CI step next to just --summary in justfile.yml and check.yml (a hook is bypassable with --no-verify). Consumers get it by bumping the submodule; there is nothing per-repo to wire up.

Protected: pre-commit, pre-commit-compose, pre-commit-docker, _pre-commit-prepare, _check-compile-step, check-tree-ownership, install-hooks, create-release, create-release-generic, create-release-workspace, create-release-virtual-workspace, _create-release, and check-justfile itself (so the guard cannot be neutered by shadowing it).

The list is narrow on purpose: it holds only the recipes where forking silently opts the repo out of a guarantee every repo shares. default is NOT protected and must not be, because every consumer has to define its own (see above). check, test, run, build, dev-clean*, dev-logs, ensure-env, and the *-docker* recipes are absent because consumers legitimately diverge on them.

Variable Default Purpose
extra_protected_recipes "" Whitespace-separated recipe names APPENDED to the protected list, for a repo that wants to guard more than the shared set. Nothing shrinks the list: a guard a repo can switch off is decoration, so a repo with a real reason to fork a protected recipe changes common.just instead.

The root justfile is resolved from just --dump --dump-format json, whose top-level source field is its absolute path, rather than a hardcoded justfile (just also accepts Justfile and .justfile). The recipe-name scan is textual for a reason: the dump flattens imports and carries no per-recipe field recording the defining file, so an imported recipe and a local one are structurally identical in it.

The divergences that drove those forks are all variables now. A Dioxus crate that builds Tailwind first, typechecks under a feature gate, and adds a wasm pass configures the shared recipe like this:

app := "my-dioxus-app"
pre_commit_mode := "docker"
dev_image := "ghcr.io/niceguyit/rust-builder-glibc:v1.0.1-rust1.94-trixie"
pre_commit_prepare := "build-css"
clippy_args := "--all-targets --features server -- -D warnings"
compile_step := "check"
compile_args := "--features server"
wasm_check := "true"
test_args := "--features server --bin my-dioxus-app"

import 'common/common.just'

If a repo needs something no variable expresses, add the variable here rather than forking the recipe there.

The reusable check workflow in .forgejo/workflows/check.yml mirrors those checks in CI. Every workflow_call input is optional and defaults to the behavior callers already get, so a caller that passes nothing is unchanged:

Input Default Purpose
compile_step "build" "build" runs cargo build --all-targets; "check" runs cargo check --workspace --all-targets. Use "check" in a workspace whose integration-test binaries are too large to link on the runner (clippy above already typechecks every target). Any other value fails the job.
test_args "--bins" Arguments passed to cargo test, mirroring the test_args just variable. Set to "" for the full suite, or "--lib" / "--all-targets" in a workspace whose tests live on library targets. Set the just variable and this input to the same value so local and CI runs agree.
cache true Caches ~/.cargo/{registry,git} and target/ via Swatinem/rust-cache@v2 with cache-on-failure: true, per governance/CI.md. A consumer migrating from a standalone check.yml drops its own cache step rather than keeping one. Set false to opt out.

runs-on is not an input: the workflow reads vars.RUNS_ON_OPENSUSE_DEV_LATEST from the caller repository's variables, because ${{ vars.* }} in a caller's with: is not carried into the expanded job's runs-on. Only inputs consumed inside run: or if: are supported for the same reason. Set runs-on on the caller job as well: this Forgejo instance does not expand a cross-repo reusable uses:, so the label has to resolve in the caller's context.

The label must point at an image with a C toolchain (opensuse-dev, not opensuse-base). Any crate with a build script links one executable, so on a toolchain-less image the job fails with error: linker `cc` not found the first time the rust-cache key misses, which is every Cargo.toml or Cargo.lock change. A warm cache hides it: clippy and check link nothing and cached build scripts are already built.

A workspace consumer that typechecks instead of linking, runs library tests, and keeps the cache:

jobs:
  check:
    uses: psa-systems/common/.forgejo/workflows/check.yml@main
    runs-on: ${{ vars.RUNS_ON_OPENSUSE_DEV_LATEST }}
    with:
      compile_step: check
      test_args: "--lib"

Justfile validation

A justfile that does not parse takes the whole task runner down: just --list, just check, just pre-commit and just create-release all exit 1, and so does the git pre-commit hook, whose stub execs just pre-commit. .forgejo/workflows/justfile.yml is a reusable, input-free workflow that makes that a red CI job. It checks out the repo with submodules: true and runs just --summary, which parses the justfile and its import targets and prints the recipe names without executing any recipe body. It exits non-zero on a duplicate recipe, an unresolvable import, and an unknown variable.

Callers of the reusable check.yml above already get this: it runs the same just --summary as its first step after checkout, and its checkout now fetches submodules. A repo that keeps a standalone check workflow (the feature and target matrix the reusable one cannot express) adds the guard as its own job:

jobs:
  justfile:
    uses: psa-systems/common/.forgejo/workflows/justfile.yml@main
    runs-on: ${{ vars.RUNS_ON_OPENSUSE_BASE_LATEST }}

The caller-side runs-on is required for the same reason as check.yml: this Forgejo instance does not expand a cross-repo reusable uses: unless the label resolves in the caller's context. The base label is correct here (the job compiles nothing) and both runner images already ship just and the nu that check-justfile runs under, so the workflow needs no install step.

The parse check cannot be a just recipe. A justfile that does not parse has no reachable recipe, so a recipe wired into check and pre-commit would fail exactly where it is meant to report. --summary rather than --evaluate: --evaluate resolves variable assignments and can run backtick expressions, so it is not side-effect free.

Both workflows run just check-justfile as a second step. That is the sibling guard, for a justfile that parses fine but silently re-forks shared logic; see Do not redefine pre-commit. It is a recipe precisely because the parse guard cannot be: by the time shadowing matters, the file parses.

Release layout

create-release bumps the crate version, syncs Cargo.lock, pushes a release/vX.Y.Z branch, and opens the release PR. It is one user-facing recipe that dispatches on release_layout to a layout-specific variant, so the Cargo.lock-sync logic lives in exactly one place (the private _create-release engine) and never re-forks per repo.

Variable Default Purpose
release_layout "generic" "generic" = crate version in the root Cargo.toml (single-crate repo); "workspace" = version in a member crate's manifest; "virtual-workspace" = the root manifest is virtual and the single version lives at [workspace.package] version, inherited by every member.
release_manifest "Cargo.toml" Path to the Cargo.toml carrying the version. Workspace consumers point this at the versioned member, e.g. crates/my-cli/Cargo.toml. Virtual-workspace consumers keep the default (the root manifest).
release_crate app Crate name passed to cargo update --package when syncing Cargo.lock. Defaults to the package.name == app convention; override when the released crate name diverges from app. Does not apply to "virtual-workspace", which has no single crate to pin and syncs the lock with cargo update --workspace --offline instead.
release_version_files "" Whitespace-separated list of additional JSON files whose top-level version is bumped to the release version and staged in the release commit. Default empty. A Dioxus consumer that carries a package.json for its JS/Tailwind toolchain sets release_version_files := "package.json" so it tracks the crate version.

Single-crate repos set nothing; the defaults produce the generic flow. A workspace consumer opts in with:

app := "my-cli"
release_layout := "workspace"
release_manifest := "crates/my-cli/Cargo.toml"

import 'common/common.just'

A virtual-workspace consumer (root Cargo.toml is a virtual manifest, version at [workspace.package] version, members inherit it with version.workspace = true) opts in with:

app := "my-server"
release_layout := "virtual-workspace"

import 'common/common.just'

A Dioxus consumer (crate version in the root Cargo.toml, plus a package.json to keep in lockstep) opts in with:

app := "my-app"
release_crate := "my-app"
release_version_files := "package.json"

import 'common/common.just'

Recipes: create-release (dispatcher, the one to run), create-release-generic, create-release-workspace, create-release-virtual-workspace (the three variants it calls), and the private _create-release engine they share. release_version_files is read in _create-release, so it composes with any layout.