Skip to main content

Contributing

Guidelines for contributing to Lumio.

Getting Started​

  1. Clone the repository (access required - Lumio is a closed-source project)
  2. Set up the dev stack: just stack-up
  3. Run the API: just dev-api
  4. Run the web app: just dev-web

Code Style​

  • Rust - Follow rustfmt.toml conventions (just fmt), pass clippy with -D warnings
  • TypeScript - ESLint + double quotes
  • Commits - Conventional commits format

Changelogs for packages and system extensions​

Each change to a public packages/* package or an extensions/system/* extension that reaches users includes a CHANGELOG.md entry in the same PR. This covers source, server code, templates, README, extension manifests, runtime dependencies and package exports. Changes limited to tests, Vitest/ESLint/TypeScript configuration, or devDependencies and scripts in package.json are exempt.

Put the entry under the new version heading when the PR bumps that unit's version, under its current heading if that version has not been published or deployed yet, or under ## Unreleased otherwise. The Packages Changes CI job checks each affected unit's PR diff, including Dependabot PRs. Before npm publish, a second gate requires the package version heading, an empty Unreleased section and CHANGELOG.md in package.json's files. System extensions are not deployed with the CLI: the API image ships their bundles and the API uploads them at startup, so the PR gate is what enforces their entries. See npm Package Publishing for the gate commands and exceptions.

Before you push​

CommandWhat it covers
just verify-allverify-rust + lint-ts + typecheck-ts - the fast inner loop
just lint-allclippy (-D warnings) + ESLint
just test-alltest-rust + test-v8 + test-ts
just verify-releaseverify-all plus OpenAPI / GraphQL schema and Bazel vendor drift - run before a version-bump or crate-update change
just bazel-build / just bazel-testthe Bazel gates CI runs for Rust - required for any Rust change
pnpm --filter @lumio/<app> buildthe production next build CI runs for web, admin, id, stats and shopify - required for any change under apps/{web,admin,id,stats,shopify}; a change under shared/, packages/protocol or to pnpm-lock.yaml builds all five (build @zaflun/lumio-protocol first for web)
pnpm -r --filter "./packages/**" buildthe tsup build CI runs for the public @zaflun/* packages under packages/ - required for any change under packages/

just verify-all does not detect schema or vendor drift; only just verify-release does.

Run these one at a time. Each recipe already uses every core it is given, so a build, a test run, a clippy pass and a format check started in parallel do not finish sooner - they contend for the same CPU and slow each other down (badly so on a shared machine). Run the checks in sequence, cheapest first - fmt-check, then lint, then check / typecheck, then tests, then the Bazel gates - and let each finish before starting the next. Do not background one or start a second alongside it, and leave the job counts the recipes set (test-rust pins CARGO_BUILD_JOBS=2) alone.

A recipe is sequential in its dependencies, but not necessarily in what it spawns: lint-ts and typecheck-ts expand to pnpm -r lint / pnpm -r typecheck, and pnpm -r runs package scripts about four at a time. On a machine you share, pass --workspace-concurrency=1 or scope the run with --filter to the packages you touched.

AI coding agents do not use just at all. The recipes are a human convenience layer; an agent runs the underlying command for each step so the steps stay individually visible, reportable and sequential. The recipe-to-command mapping and the reasoning live in AGENTS.md, section Verification - Agents run the tools directly.

Cargo green is not Bazel green. CI builds and tests the Rust workspace with Bazel, which fails on things Cargo never sees - a dependency missing from a hand-written BUILD.bazel, a test that reads a fixture off the filesystem instead of embedding it, or vendor/ drift after a lock change. For any change touching Rust, run just bazel-build and just bazel-test before you push.

Typecheck green is not next build green. tsc never resolves Next.js file conventions, route manifests, server/client boundaries, the React Compiler or prerendering. The Next Build job runs the real production build per affected app - the same build the release image performs - so run pnpm --filter @lumio/<app> build locally for any frontend change. It is path-filtered: an app builds when its own directory changed, and a change under shared/, packages/protocol/, the root manifests or ci.yml builds all four.

Always-dynamic route groups declare themselves. serverGqlSSR() reads the auth cookie, so during the production build's static-generation pass Next throws a DynamicServerError (digest DYNAMIC_SERVER_USAGE) purely as a signal to bail the route out of prerendering. Our SSR pages wrap their data loads in try/catch so a failed query renders a visible error instead of an empty success state - and that catch swallows the bail-out signal, turning it into a build-time Failed to load ...: Error: Dynamic server usage or, silently, into a notFound() / redirect() fired at build time. The fix lives at the segment root rather than in every catch: the always-dynamic route groups in apps/web and apps/admin export const dynamic = "force-dynamic" from their layout.tsx, which Next inherits down to every page in the group. It changes no output - those routes were already server-rendered on demand - it only skips a prerender attempt that could never succeed. Add the same line when you introduce a route group whose layout or pages call serverGqlSSR(); if a segment genuinely must stay prerenderable, start its catch blocks with unstable_rethrow(err) from next/navigation so Next's own errors pass through.

Typecheck green is not tsup build green either. The public @zaflun/* packages under packages/ are bundled by tsup (entry resolution, export map, .d.ts rollup) - none of which tsc --noEmit or ESLint exercise, so a broken entry or export only surfaced at npm publish time. The Packages Build job runs pnpm -r --filter "./packages/**" build in dependency order (so cli and sdk get their built dependencies first); run it locally for any change under packages/. It is path-filtered: any packages/ file, a root manifest, or ci.yml arms it.

Pull Requests​

  • Create a feature branch off next and open the pull request into next, the staging integration branch
  • Open every pull request as a draft (gh pr create --draft) and mark it ready for review (gh pr ready) only once the work is finished
  • Include tests for new functionality
  • Update apps/docs/ in the same change - a code change without matching doc updates is incomplete
  • Ensure CI passes and the change is reviewed before it merges

Why draft first​

CI is draft-gated: every job in .github/workflows/ci.yml is skipped while a pull request is a draft, so iterating on a draft costs no GitHub Actions minutes. The full pipeline runs when the pull request is marked ready for review, and on every push after that.

Two consequences worth knowing:

  • Verify locally while drafted - just verify-all and just test-all, plus just bazel-build / just bazel-test for a Rust change and just verify-release for a version bump or crate update. That is what replaces the per-push CI run, so run all of it before marking the pull request ready, and say in the pull request which checks you ran.
  • A draft's green checks are not evidence. The CI Done aggregate treats skipped jobs as passing, so a draft shows green with nothing run. Only a run triggered by marking the pull request ready - or by a push while it is not a draft - proves anything.

See Reporting an Issue for where each kind of report goes and how a fix travels from next to production.

Architecture​

See the Developer Guide section for detailed system design documentation.