Status: Accepted Date: 2026-05-03
Common dev commands (compose up, migrate, lint, test, shell into a container) appeared as raw docker compose ... invocations sprinkled across README.md, docs/developer/quickstart-guide.md, docs/developer/devops.md, docs/developer/installation.md, and CI snippets. The same command would land in different forms in different places, drift independently, and become a discovery problem for new contributors ("which page has the right invocation?").
The pre-rewrite Makefile existed but was broken: it referenced a removed linter compose service, declared a dead test-frontend in .PHONY with no body, ran python manage.py test server.tests against an ctj_api app, and named a db-shell target that actually ran manage.py shell (Django's Python REPL), not psql. Reaching for it surfaced more friction than running raw docker compose commands directly, so contributors stopped using it.
Locking in the right canonical surface now is cheap (the Makefile is a single file, and the lint, test, and DB commands are stable post-modernization). Done later, the wrong invocations leak into more docs and CI snippets.
The Makefile at the repo root is the canonical surface for common dev commands. Targets are namespaced by surface; destructive operations gate on a confirmation prompt; make lint delegates to the pre-commit framework rather than reimplementing it; help is the default goal.
Concrete rules:
-
Namespace prefixes signal which surface a target operates on:
local-*- host processes (Poetry-run Django,npm run dev, host-side migrations).docker-*- stack-level operations (up,down,logs,watch).db-*- database-scoped operations (up,migrate,reset,reset-hard,grant-test-db-perms).docker-shell-*- shell access into a running container.lint,install-hooks- linting layer (un-namespaced; one of each).
-
Destructive operations require a typed
yesconfirmation before execution. Pattern:db-reset: @echo "This will truncate all app data... Type 'yes' to confirm:" @read ans && [ "$$ans" = "yes" ] || (echo "Aborted."; exit 1) $(DEV_COMPOSE) exec $(BACKEND_SERVICE) python manage.py flush --noinput
Currently applied to
db-reset(truncate data, keep schema) anddb-reset-hard(drop volume, recreate). -
make lintshells out topre-commit run --all-filesrather than enumerating ruff, mypy, bandit, eslint, stylelint, knip, tsc, prettier individually. This means: one source of truth for the lint suite (the.pre-commit-config.yaml+ the matchinglint.ymlCI workflow), andmake lintis what runs in CI. No drift between local and CI lint. -
helpis.DEFAULT_GOAL: baremakeprints a categorized command reference. Targets are documented in thehelpblock and only there, so the README and quickstart can link tomake helprather than re-listing every target. -
Variables for compose invocation, services, and ports at the top of the file (
DEV_COMPOSE,DB_SERVICE,BACKEND_SERVICE,FRONTEND_SERVICE,LOCAL_KILL_PORTS). Service names are the only things that change when the compose file evolves; the rest of the file references the variables.
-
just(or another modern task runner). Better syntax, no tab-indentation traps, named arguments. Discarded because Make is universally available on dev machines (macOS, Linux distros, WSL) without a separate install, andjust's ergonomic wins are small relative to the friction of "install this tool first." -
Shell scripts collection (
scripts/dev-up.sh,scripts/db-reset.sh, etc.). Nohelpconvention out of the box; discoverability requires reading the directory or the README. Discarded because Make'shelptarget solves discoverability for free, and the same per-task confirmations and dependency chains work better as Make targets than as ad-hoc scripts. -
npmscripts only (infrontend/package.json). Works for frontend-only projects but CTJ has a backend (Poetry) and a database (Docker compose). Discarded because the canonical surface needs to span all three, and putting Django commands behindnpm runwould be confusing. -
Document raw
docker compose ...commands in the README and docs (current pre-rewrite state). Discarded because the same command landed in different forms in different docs (see Context); each doc became a maintenance liability. -
Have
make lintrun ruff/mypy/bandit/eslint/stylelint/knip/tsc/prettier individually. Gives finer Make control over ordering and output formatting. Discarded because pre-commit already orchestrates the suite, runs the same way in CI, and putting the orchestration in Make duplicates it. If we want a "fast subset" later (e.g. ruff check + eslint, no type checking), that's an additivemake lint-fasttarget. -
Skip the host-process targets (
local-run-frontend,local-run-backend); make Docker the only path. Discarded because rapid iteration on host (no rebuild, no compose round-trip) is meaningfully faster, and thelocal-check-dbdependency keeps the failure mode clean ("DB not reachable on :5432. Run 'make db-up' first.").
Accepted:
-
Make is a soft dependency on dev machines. Universally available, but not pre-installed in minimal CI containers (we don't run
makein CI today; CI invokes the underlying tools directly vialint.yml). -
Targets must stay in sync as
docker-compose.ymlorpyproject.tomlevolves. Mitigated by the variables-at-the-top pattern and PR review. -
Make's tab-indentation rule is unforgiving. Editor config (
.editorconfigor VS Code's per-file indent settings) needed to avoid silent breakage. -
The
helpblock is hand-maintained. New targets must be added in two places (the recipe and the help echo). Mitigated by PR review; the alternative (auto-generatinghelpfrom##comments) is more machinery than the small target list justifies.
Won:
-
One canonical surface. README and docs link to
make helprather than re-listing commands; commands stop drifting. -
New contributors run
make helpand see the full command vocabulary in one screen. -
Destructive operations (
db-reset,db-reset-hard) require an explicityes, eliminating a class of "I meant to type the other thing" mistakes. -
make lintand CI run the same lint suite; "passes locally, fails in CI" stops being a pre-commit drift question.