Contributing to ELAS
The one page to read before you open a PR. It ties together how we build (spec-driven), how we test, and how CI/CD ships it. Deep references are linked inline — this is the map, not the territory.
0. The five rules (non-negotiable)
Every change respects these (full detail in BUILDING.md):
- Multi-tenant — everything scoped to an
Org(orgId); never leak across tenants. - Permission- & license-governed — the server decides who can do what (
libs/access.ts,ACCESS.md). The UI only mirrors it. - Agent-ready — if it's an action, expose it as an MCP tool (
libs/artie/tools.ts) so Artie can do it too, under the same governance. - Mobile-responsive — works at 375px or it's not done.
- Reuse, never recreate — build on existing components/hooks/libs; don't fork a second version of something that exists.
Golden rule: the API/server is the source of truth.
npm run buildmust be green andnpm testmust pass before you commit. Branch offmain— don't commit straight to it.
1. The workflow: spec-driven
Full SOP: SESSION_SUMMARY.md. The short version:
Step 0 — Triage
npx tsx scripts/triage.ts "the change you want to make"
It tells you backend / frontend / both, whether a migration is likely, and which path to follow.
- Frontend-only (copy, layout, a component, what's shown) → skip the spec. Edit the screen/component, gate with
useAccess().can(...), verify at 375px. Done. - Backend / both (new data, fields, rules, API, permissions, jobs) → write a spec first (next step).
Step 1 — Write the spec (specs/<feature>/)
One folder, three files — the reviewable source of truth:
specs/<feature>/
requirements.md # WHAT & WHY + numbered, testable acceptance criteria
design.md # data model + API contract + rules→location + UI hints
tasks.md # the ordered build checklist
See specs/org-chart/ and specs/timesheet-settings/ for worked examples, and SESSION_SUMMARY.md for templates.
Step 2 — Build it
Implement what the spec describes. For most new record types this is the ERP-resource
shortcut (no migration): register in ERP_RESOURCES → add the type in ResourceTypeMap
→ map the module in RESOURCE_MODULE. Mechanics + the agent-tool/MCP/integration recipes
are all in BUILDING.md.
Step 3 — Test it
Write/extend unit tests for any pure logic you added (see §2). npm test is now a CI gate.
Step 4 — Migrate (only if you added drizzle/000N_*.sql)
npx tsx scripts/pg/migrate.ts # privileged role via MIGRATE_DATABASE_URL — see BUILDING.md §G
Step 5 — Open a PR
The reviewable unit is the spec + the diff. Migrations get extra scrutiny.
2. Testing
We use Vitest. Tests live next to the code as *.test.ts.
npm test # run the whole suite once (CI uses this)
npm run test:watch # watch mode while developing
npm run test:coverage # coverage report (text + html + lcov)
What to test
Pure logic in libs/ — functions that are deterministic given their inputs (no DB,
network, or session). That's where bugs hide and where tests pay for themselves:
hierarchy/cycle math, permission resolution (can/permFor), date/period boundaries,
ingest transforms, money/overtime/accrual calculators, rollups.
Don't unit-test DB-bound or route handlers here — those are covered by the spec's acceptance criteria + the typecheck/build. If a function needs the DB, extract the pure core and test that.
The pattern
import { describe, it, expect } from "vitest";
import { availableParents } from "@/libs/orgchart/hierarchy";
describe("availableParents", () => {
it("excludes the node and all its descendants", () => {
expect(availableParents(people, "1").map((p) => p.id)).toEqual(["99"]);
});
});
- Import real modules via the
@/...alias (configured invitest.config.ts). - Derive expected values from the implementation, not from guesses.
- A pure function that's hard to test is usually a design smell — pull the logic out.
See libs/orgchart/hierarchy.test.ts and
libs/access.test.ts for reference.
3. CI/CD — what runs, and when
Two GitHub Actions workflows. The app lives at the repo root (this repo is the app —
there is no shipfast/ subdir; don't add working-directory:).
CI — .github/workflows/ci.yml
Runs on every PR and every push to main:
install → spec:check → lint (advisory) → npm test → build → typecheck
The hard gates are spec:check (committed code must match the spec generator), npm test,
build, and typecheck — any of those red makes the PR red. Lint is advisory (continue-on-error)
for now: there's a pre-existing ESLint backlog, so it's surfaced but doesn't block. Keep the hard
gates green.
CD — .github/workflows/deploy-web.yml
Runs on push to main (path-filtered to app code). It is gated: a verify job
(spec:check + advisory lint + npm test) must pass before the image is built — and the
image build itself runs next build (which typechecks) — so a red commit can never reach an
environment. A merge auto-deploys QC only; staging and prod are one-click promotions of the
same tested image (promote-staging.yml / promote-prod.yml, or the in-app /admin/releases screen).
verify (spec:check, lint(advisory), test)
└─ build image (next build = typecheck) → push to ECR (elas-web :latest+:<sha>) → App Runner rolls QC
└─ [one-click] Promote to staging (retag :staging) → [one-click] Promote to prod (retag :prod)
- Auth is GitHub OIDC → the
elas-gha-deployrole (no long-lived AWS keys). - App Runner has AutoDeploy on, so the ECR push itself starts the rollout; the explicit
start-deploymentstep tolerates the "already deploying" race instead of failing. - Runtime config is SSM Parameter Store (
/elas/web/*) + Secrets Manager — not a.env. Add a new required var to SSM (+ Terraformenv_keys), not just.env.local. - If your change adds a migration, apply it before the deploy lands (CI/deploy do not
run migrations) — see
BUILDING.md§G.
Full hosting runbook: AWS_MIGRATION.md.
Releases — .github/workflows/release-please.yml
Versioning + release notes are automated by release-please. On push to main it keeps a
rolling "chore(main): release X.Y.Z" PR that bumps package.json and regenerates
CHANGELOG.md from your commit messages; merging that PR tags vX.Y.Z and publishes a GitHub
Release with auto-generated notes. (Deploy is fully separate — merging the release PR stamps a
version but ships nothing; deploy-web.yml auto-deploys QC on every push to main, and staging/prod
are one-click promotions.)
- Write Conventional Commits so your change shows up:
feat: …→ minor,fix: …→ patch,feat!:/BREAKING CHANGE:→ major. Other prefixes are ignored for versioning. (Scopes are fine:feat(orgchart): ….) - Roadmap (specced, not yet built): curated
release-notes/vX.Y.Z.md→ a public/releasespage + in-app "What's new" (specs/release-pipeline).
4. Local setup
npm install
npm run dev # http://localhost:3000 (dev-auth bypass lands you in the demo org)
npm run dev:mock # same app on MSW fixtures — NO DB/backend (mocks/fixtures.ts)
npm run storybook # components in isolation at http://localhost:6006
npm run build # production build — must be green before commit
npm test # unit tests — must pass before commit
For pure-UI work you don't need a database: dev:mock (sets NEXT_PUBLIC_API_MOCKING=enabled,
boots MSW from mocks/handlers.ts) and Storybook both render on the shared fixtures in
mocks/fixtures.ts. Add a *.stories.tsx next to a component to isolate it. Minimal .env.local
and the dev-loop flags are documented in the README §8.
5. Definition of done
- Triaged; spec written + reviewed if it's backend/both.
- Org-scoped; server enforces permission; UI mirrors with
useAccess. - Action → an MCP tool with a permission (+ undo if reversible).
- Discoverable: new screen wired into the sidebar +
lib/features.ts. - Unit tests added for new pure logic;
npm testgreen. - Mobile-checked at 375px.
-
npm run buildgreen; committed on a branch (notmain) with aCo-Authored-Bytrailer. - Docs updated if you added a pattern/permission/module.