Skip to main content

Contributing to KilnCMS

Thanks for contributing! This guide covers the development workflow and the conventions that keep the codebase consistent. For the project vision and architecture see KilnCMS_Project_Plan.md; for the authoritative, always-in-context coding rules see AGENTS.md (which also links the per-package Ash/Phoenix usage rules).

By participating you agree to the Code of Conduct. Found a security problem? Don't open an issue — follow the security policy and report it privately.

Getting set up

See the Getting started section of the README for the full setup (docker compose up -d postgres, then mix setup). A few environment notes that bite people:

  • mix must be on your PATH (Homebrew installs to /opt/homebrew/bin).
  • First run fetches 102 MB, not 773 MB. The ML stack behind semantic search (Bumblebee, Nx, and EXLA — 671 MB of deps/, plus a one-time 110 MB XLA archive download) is opt-in, because semantic search itself ships disabled. mix setup prints one line saying it was skipped. Working on that feature? Export KILN_ML=1 and re-run mix deps.get && mix compile, and keep it exported for every mix invocation after that — including mix test and mix precommit, which skips deps.unlock --unused without it (that command would otherwise strip the opted-out deps out of mix.lock). CI's Optional ML stack (semantic search) job is the leg that compiles and tests that shape.
  • Clone to a path without a space. One dependency cannot compile under such a path: picosat_elixir, Ash's policy SAT solver. Its Makefile uses absolute paths as make targets, and make splits them at the space. The resulting error says to install gcc and make, but they are not the cause. mix setup stops before deps.get if the path has a space. The upstream fix, bitwalker/picosat_elixir#14, is not released yet. Every other native dependency builds under a spaced path. iCloud Drive's folder (~/Library/Mobile Documents/…) fails only because its name has a space.
  • OTP 29.1.1's :httpc header limit is lifted for Mix only. inets 9.8 caps response headers at 10240 bytes, which GitHub's release redirects have exceeded, so tailwind and the precompiled NIFs of y_ex, lazy_html and vix failed to download with {:header_too_long, {:max, 10240}}. mix setup, mix assets.setup and mix deps.compile build a patched httpc from rel/docker/httpc_header_limit.escript into _build/httpc_patch/ and load it into that Mix VM only; your OTP install is never written to. A plain mix compile or mix tailwind.install on a fresh tree does not get it, so run mix setup (or mix deps.compile) first.
  • igniter is a real dependency. mix kiln.gen.content and mix kiln.gen.plugin are Igniter tasks. Removing it does not crash the compiler; it removes those two generators.
  • Node.js is required for assets — the editor bundles JS deps (TipTap) that esbuild pulls from assets/node_modules. mix setup runs npm install for you; otherwise run npm install in assets/. assets/node_modules is gitignored; assets/package-lock.json is committed.

mix setup seeds a demo admin ([email protected] / kilnadmin123) and editor ([email protected] / kilneditor123); override with the ADMIN_* / EDITOR_* env vars. Sign in at /sign-in, or use AshAdmin at /admin (dev only).

In dev, AshAdmin uses your signed-in user as its actor automatically, so admin actions run under real RBAC policies — sign in as the editor and you'll only see what an editor is allowed to do. You can still impersonate a specific record by picking an actor from the AshAdmin toolbar (that choice overrides the default). This wiring lives in KilnCMSWeb.AshAdmin.ActorPlug and is enabled only when dev_routes is on (config :ash_admin, :actor_plug, ... in config/dev.exs).

Development workflow

Modeling is done in Ash — never hand-write migrations

KilnCMS models its domain with Ash resources. To change the schema:

  1. Edit the resource (e.g. lib/kiln_cms/cms/page.ex).
  2. Generate the migration and resource snapshot:
    mix ash.codegen <descriptive_name>
  3. Apply it:
    mix ash.migrate

Commit the generated migration and snapshot together with the resource change. Don't hand-edit files under priv/repo/migrations/ or priv/resource_snapshots/ — they're generated and checked.

CI runs mix ash.codegen --check, which fails the build if the committed migrations/snapshots don't match the resources (i.e. you edited a resource but forgot to run mix ash.codegen). Run the same check locally before pushing:

mix ash.codegen --check   # exits non-zero when codegen is pending

Every action gets a domain code interface

Call into resources through the domain code interfaces (CMS.create_page!, CMS.list_pages!, Accounts.get_user_by_email, …) — not raw Ash.create!/read! — in app code, seeds, and tests. When you add an action, add a matching define :name, action: :name on the domain. Ash also generates can_*?/2 helpers (e.g. CMS.can_publish_page?(actor, page)) — use them for authorization-driven UI.

Authorization is mandatory

Every domain/content resource uses Ash.Policy.Authorizer. The role model (User.role → :admin / :editor / :viewer):

  • published content is world-readable (headless delivery); unpublished is editor-only,
  • create/update + workflow transitions require editor (or admin),
  • hard deletes are admin-only, admins bypass.

A new resource without policies is a bug. Pass the current user as actor: to code-interface calls so policies can evaluate.

Quality gate

Run this before every PR — it's the same gate CI enforces:

mix precommit

It runs: compile --warnings-as-errors, deps.unlock --unused, format, credo --strict, sobelow (security scan), deps.audit and hex.audit (dependency CVE scans, see below), kiln.plugins.doctor, and the test suite. CI (.github/workflows/ci.yml) additionally runs mix ash.codegen --check (migration/snapshot drift, see above), mix dialyzer (the first local run builds a PLT and is slow; it's cached afterwards), and a gettext catalog gate — mix gettext.extract --merge must leave priv/gettext unchanged. That gate is CI-only and is not part of mix precommit, so run it yourself before pushing any change that adds, removes or rewords a gettext(...) string:

mix gettext.extract --merge && git diff --exit-code -- priv/gettext

Merely moving code no longer drifts the catalogs. mix.exs sets gettext: [write_reference_line_numbers: false, sort_by_msgid: :case_sensitive] for exactly that reason: reference comments keep the file name but not the line number, and entries are sorted by msgid rather than by wherever the extractor happened to find them.

Before that, a five-line shift in a large LiveView rewrote ~3,300 catalog lines and re-conflicted every other open PR — which made merging strictly serial and cost several branches three rebases each.

So if your diff touches no gettext(...) string, expect priv/gettext to come back clean, and treat churn there as a signal that something really did change.

Documentation

CI builds the docs with mix docs --warnings-as-errors in its own job. Like the gettext gate this is CI-only, so run it yourself if you renamed a file under docs/, changed a link between guides, or wrote a moduledoc that references another module or function:

mix docs --warnings-as-errors

It fails on dead cross-references — a docs/ file listed in extras that no longer exists, a `Module.fun/2` that was renamed or changed arity. Adding a new guide means adding it to both extras and groups_for_extras in mix.exs; an unlisted guide is silently invisible in the generated sidebar.

"View Source" links resolve against the release tag matching mix.exs's version, so in a local build they point at the last release rather than at your branch. That only matters if you are checking the links themselves; pin them to what you built with DOCS_SOURCE_REF=$(git rev-parse HEAD) mix docs.

A link to a README.md is written as a full URL. ExDoc resolves a relative link between guides by basename alone — it ignores the directories you wrote and takes whichever extra with that basename was registered last in mix.exs. Four extras are called README.md (the root one plus examples/, projects/ and clients/elixir/kiln_client/), so [Overview](../README.md) rendered as a link to the Elixir client's page, and mix docs --warnings-as-errors reported nothing: it warns only when a basename is missing entirely, never when it resolves to the wrong page. Link any README by its full https://github.com/The-Verscienta/kiln_cms/blob/main/… URL, which is right both on github.com and in the generated docs — the same reason .github/SECURITY.md and AGENTS.md are linked that way. A test (test/kiln_cms/docs/extras_links_test.exs) fails the build if any relative link between extras renders as a link to a different file than the one it names; the fix is the full URL, never reordering extras:, which only moves which of the colliding links is wrong.

A #fragment link must land in both renderings. ExDoc and github.com slug headings differently: GitHub keeps a hyphen for each space around dropped punctuation (## Storage & CDN → storage--cdn) where ExDoc collapses the run (storage-cdn), an apostrophe or slash splits the word in ExDoc and not on GitHub (What's → what-s vs whats), and ExDoc gives ids only to ## and ### headings. --warnings-as-errors never looks at fragments. The same test fails when a fragment between extras is missing from either slug set. Fix it by linking the file without a fragment and naming the section in the sentence ("see Storage & CDN in …"), or — once you have checked that nothing outside the docs uses the old anchor — by rewording the heading so both slugs agree (## Storage and CDN).

Plans and spikes carry a Status: line. Every docs/*-plan.md and docs/*-spike.md states its status in its first lines — Status: shipped, Status: design only, Status: historical snapshot (date) — and a test (test/kiln_cms/docs/status_header_test.exs) fails the build when one is missing. If the document also has a phase table, the header summarizes the table and changes in the same commit as it. A header that says "design only" above a table of "done" rows is the drift this rule exists to stop.

docs/ is the published guide tree. Agent work-plans and scratch specs don't belong in it; the PR description is where a finished plan lives.

Dependency audit

mix deps.audit (mix_audit) checks mix.lock against mirego's mirror of the Elixir security advisory database; mix hex.audit checks it against the advisories Hex itself serves. Both run in mix precommit and in the Dependency audit CI job, because the two databases drift: on 2026-09-18 the mirror carried none of the 89 advisories (six CRITICAL, all in ash_authentication) that Hex listed against the v0.9.0 lock.

Because the advisory database moves independently of this repo, this gate can fail on a PR that touched no dependencies — that means a new advisory landed against something already locked, not that your change broke anything. It has its own job precisely so that failure doesn't bury the lint/test results of an unrelated change. Remediate by upgrading the affected dependency; if no fixed version exists, document the accepted risk and acknowledge the advisory (mix_audit's ignore options; the :hex section of mix.exs for hex.audit) rather than dropping the check.

The job fetches the advisory database explicitly before running the audit. mix_audit clones it at run time and ignores the exit status of its own git commands, so a failed fetch would otherwise leave it with zero advisories and a green "No vulnerabilities found" — a pass that verified nothing.

Testing

  • Use KilnCMS.DataCase for data-layer tests (SQL sandbox).
  • Test domain behaviour through the code interfaces; test authorization with the generated can_*? helpers or Ash.can?.
  • Use globally unique values for identity fields ("...-#{System.unique_integer([:positive])}@example.com") to avoid concurrent-test deadlocks.
  • Seed fixtures with Ash.Seed.seed! when you want to bypass the very policies/actions under test.

Coverage

CI runs the suite under line coverage (excoveralls) and enforces a floor, not a target: minimum_coverage in coveralls.json sits just under the number the full suite last measured, so coverage cannot regress silently. When a PR raises the measured total by a whole point or more, raise the floor to just under the new number in the same PR; never lower it to turn a red build green — a build red on the floor is the regression it exists to surface. In CI the suite runs under coverage across the Test (shard n) jobs, each of which exports its raw :cover data; the Coverage (full suite) job imports the lot, checks the floor on the union, uploads the HTML/JSON report as the coverage-report artifact, and carries the per-directory rollup in its job summary. A shard's own percentage is partial and means nothing on its own — only the merged number is compared to the floor. Locally:

mix coveralls.multiple --type json --type html   # full suite under cover; writes cover/
mix kiln.coverage.summary                        # one row per source directory

Cover-compiled modules run slower, so this takes longer than mix test. The usual mix test / mix precommit do not measure coverage.

Browser E2E (Playwright)

LiveView tests cover server-side events; the browser E2E suite (in e2e/) drives a real headless Chromium through the editor — TipTap rich text, SortableJS drag-reorder, the create → edit → publish → view-live journey, content-list bulk actions, media upload + focal point, release create → ship → roll back, dynamic content-type creation, and comment threads with @mention. It runs in a dedicated MIX_ENV=e2e against its own kiln_cms_e2e database (no SQL sandbox — the browser hits the server out-of-process).

cd e2e
npm install
npx playwright install chromium   # bundled browser; no system Chrome needed
npx playwright test               # boots the server itself, then runs the suite

Playwright's webServer runs mix e2e.setup (build assets + create/migrate/seed the e2e DB) and then serves with PHX_SERVER=true PORT=4002 mix phx.server. To run against a server you started yourself, set E2E_NO_WEBSERVER=1. CI runs this suite as a separate e2e job (see .github/workflows/ci.yml).

Locally the harness reuses a server already listening on the port (reuseExistingServer), so after pulling a change to config/e2e.exs or the seeds, stop that server (or check nothing else — a sibling worktree, say — is holding :4002; PORT=4012 POSTGRES_DB=kiln_cms_e2e_mine npx playwright test runs isolated). Several journeys depend on server-side state only a fresh boot delivers: the media and release journeys need Oban queues running, the @mention journey needs the Swoosh mailbox route and named demo users.

Commits & pull requests

  • Branch off main; keep commits focused with a clear imperative subject line.
  • Ensure mix precommit passes and update KilnCMS_Project_Plan.md's TODO checklist when you complete a planned item.
  • Open a PR against main; CI must be green before merge.