- Just 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| .gitignore | ||
| CLAUDE.md | ||
| common.just | ||
| README.md | ||
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 sharedjustrecipes (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.