Contributing
Thank you for your interest. Ushabti is small on purpose, and it stays small. This page tells you how to work on it.
Before you write code
Section titled “Before you write code”Open an issue first for anything larger than a bug fix. A short description of the problem is enough. This prevents wasted work on a change that does not fit the project.
Read ROADMAP.md first. It lists what comes next and what the project will not do.
Taking an issue
Section titled “Taking an issue”Nobody is assigned. To take an issue, comment with your plan: the files you will change and how. A plan that shows you read the code is the claim. “I’d like to work on this” is not, and gets no answer.
If two people post a plan, the maintainer picks one, and the issue says so. An
issue can also stay with the maintainer. When it does, it loses
help wanted and good first issue, and a plan does not change that.
Take one issue at a time until your first pull request is merged. A claim with no pull request after two weeks lapses, and the issue is open again.
Using AI
Section titled “Using AI”AI is welcome. You still answer for every line, so read what it wrote before you send it. Say in the pull request that you used it.
In review, answer in your own words. A pull request whose author cannot explain it is closed, however green CI is. Read AGENTS.md and point your tool at it: it holds the rules that are easy to break, and a change that breaks one is sent back.
Set up
Section titled “Set up”You need Docker. Nothing else.
cp .env.example .envdocker compose updocker compose exec app npm run db:seed # demo data, in a second terminalThe app is then at http://localhost:3050.
docker compose up also starts RustFS, the store a task’s files go to, and
the app makes the bucket ushabti in it as it starts. RustFS shares the
network of app, so it is
at http://localhost:9050 both inside the container and on your machine: a
signed URL names the host it was signed for, and the server and the browser
must reach the bucket by the same one. app gets the S3_* values for it.
scripts/dev-server.mjs starts next dev again when it crashes, so the
container keeps its network. If you restart app by hand, recreate rustfs
too: docker compose up -d --force-recreate rustfs.
Unset S3_BUCKET in a docker-compose.override.yml to work with attachments
off. The variables are in README.md.
To stop a secret before it leaves your machine, install gitleaks and turn on the hook once per clone:
git config core.hooksPath .githooks.githooks/pre-push then scans the commits each push adds, and refuses the
push if one of them holds a key, a token or a password.
The dev server answers localhost and blocks its own live-reload resources for
every other name. Reached by another name, the page never comes alive and the
login form posts as plain HTML with no error on screen. To reach it from another
computer by a name, set ALLOWED_DEV_ORIGINS to those names, comma separated.
In a docker-compose.override.yml, give them to the app:
services: app: environment: ALLOWED_DEV_ORIGINS: devbox.local,192.168.1.50Outside Docker the shell does the same: ALLOWED_DEV_ORIGINS=devbox.local npm run dev.
Write the host name only, without the scheme and without the port.
next.config.mjs reads the variable, and only next dev uses it. Unset, the
default stands, and the production build is the same either way.
Docker Desktop does not pass the event for a new file into the container, so
scripts/dev-route-watch.mjs restarts the dev server when a route file comes
or goes; a new route answers within about 10 seconds. An edit made in the
second of that restart can be missed: save the file again. If a new route
still answers 404, run docker compose restart app; do not start a second
next dev beside it.
Before you open a pull request
Section titled “Before you open a pull request”Run these. All of them must pass, because CI runs the same list.
npm run format # Prettier writes the filesnpm run lint # ESLintnpm run typecheck # the app and the specsnpm test # unit, route and component testsnpm run test:e2e # Playwrightdocs/testing.md says where a new test belongs: a unit, a
component, a route or an end to end test. A component test runs in Chromium,
so npm test needs the browser Playwright installs.
Playwright runs four files at once, each in order. The specs that listen on
the one test SMTP port run one at a time, in the mail project of
playwright.config.ts; a new spec that shares state with another file goes
there too.
About thirty tests carry the tag @smoke: one or two per main flow, each the
test that walks the flow end to end. They answer “is anything big broken?” in
about a minute, so run them after each change, with the specs you touched:
npx playwright test --grep @smokeThe whole suite still runs before a push. A smoke test must pass against the dev server on 3050, so none of them reads mail, a webhook or an upload. Tag a new one only when it walks a main flow that has none.
The end-to-end tests need a server. Start the dev one with docker compose up,
or let Playwright start one for you. On CI, and whenever CI is set, Playwright
runs npm run start instead, so the tests exercise what the image ships.
npm run start is the production build: node .next/standalone/server.js, the
same server the image runs. PORT picks the port, and it binds 0.0.0.0
unless HOST names another address. It carries the copy of .env the build
made, not the file on disk, so anything you changed since then — or never had —
has to come from the command line. Build first, then start it on a free port
and point the tests at it:
npm run buildDATABASE_URL=postgres://ushabti:ushabti@localhost:5435/ushabti PORT=3101 npm run startBASE_URL=http://localhost:3101 npm run test:e2ePoint the tests at localhost and never at a bare IP, because the session
cookie of a production build is Secure and Playwright’s request context will
not send a Secure cookie to an IP address. playwright.config.ts refuses
such a BASE_URL and says the same thing.
Set CI as well and Playwright starts that same server for you, on PORT.
It builds http://localhost: and the port itself, so this recipe needs no
BASE_URL.
DATABASE_URL belongs on this one too, because the server it starts is the
standalone one and reads the .env the build copied rather than the file on
disk. Without it every route answers 500 and Playwright only says it timed
out:
The S3_* values belong on it for the same reason. Under CI the attachments
spec fails, and does not skip, when the server has no bucket, because CI always
has one. These are the dev compose’s RustFS, which answers on localhost:9050
while docker compose up runs. The upload specs send a file from the page, and
RustFS lets a page on localhost:3101 do that, so keep the port:
CI=1 PORT=3101 DATABASE_URL=postgres://ushabti:ushabti@localhost:5435/ushabti \ S3_ENDPOINT=http://localhost:9050 S3_REGION=us-east-1 S3_BUCKET=ushabti \ S3_ACCESS_KEY=ushabti S3_SECRET_KEY=ushabti-secret S3_FORCE_PATH_STYLE=true \ npm run test:e2enpm run start binds 0.0.0.0 because the image does, and because a shell
that exports HOSTNAME — docker exec does, and so do some Linux profiles —
would otherwise hand the server the machine’s own name to bind, and
localhost would refuse. Use HOST when you mean a different address:
HOST=127.0.0.1 PORT=3101 npm run start. That is the address the server
listens on; BASE_URL still says localhost.
Rules for a change
Section titled “Rules for a change”- Keep properties dynamic. No field on a task is hardcoded — not Status, not Priority. A change that adds a fixed field works against the idea of the product. Add a property type instead.
- Add a test. A new rule in
src/libneeds a unit test. A new screen or interaction needs a Playwright test ine2e/. - Keep the interface quiet. The board has no dialogs. Fields edit in place,
and they save on blur. When something destroys data, do not add a dialog: use
ConfirmRow, which turns the row itself into the question and names what is lost. “Delete Labels? 5 options and 14 values go with it.” beats “Are you sure?” every time. - Off the board, use
components/ui/. Button, Input, Field, Card, Tag, Toasts, EmptyState, ConfirmRow, CopyField. Do not declare a fourth button. Geometry, radius and type come from the tokens at the top ofglobals.css. - Write plain English in the interface and in comments. Short sentences.
- Add a line to CHANGELOG.md under “Unreleased” if a user
would notice the change. When “Unreleased” already has the heading you need,
such as
### Fixed, add your entry under it. Never add a second copy of a heading:.gitattributesmerges this file by union, so two branches that add entries under one heading meet without a conflict.
If you change the database
Section titled “If you change the database”- Edit
src/db/schema.ts. - Run
npm run db:generate. This writes a new file indrizzle/. - Commit that file with your change.
The dev container uses drizzle-kit push, so your local database follows the
schema immediately. A migration file is still necessary, because self-hosted
installs upgrade with npm run db:migrate.
A pushed database has no rows in drizzle.__drizzle_migrations, so
db:migrate replays 0000_init.sql on it and fails on tables that exist. To
try a new migration file on the dev database before it ships, mark the pushed
database as migrated first. Do these steps in place of steps 1 and 2 above:
- With the app container running and before you edit the schema, run
docker compose exec app npm run db:baseline. It marks every file indrizzle/meta/_journal.jsonas applied and changes no schema. A second run changes nothing. - Edit
src/db/schema.tsand runnpm run db:generate. - Run
docker compose exec app npm run db:migrate. It applies only the new file and ends with “migrations applied”.
Do not restart the app container between the steps: it pushes the schema as
it starts, and then the new file fails on what push made. If you ran
db:baseline after db:generate, it marked the new file as applied too. To
go back, remove those marks with
docker compose exec db psql -U ushabti -c 'DROP SCHEMA drizzle CASCADE',
remove the new file with its journal entry, and start again at step 1. Never
run db:baseline on a database that push did not build: it would skip
migrations that never ran.
- TypeScript, strict mode. ESLint refuses
any. - Prettier decides the formatting. Do not argue with it; run
npm run format. - CSS modules. No CSS framework.
- Comments explain why, not what.
- Small commits with a clear subject line.
Where things are
Section titled “Where things are”src/app/ routes: pages and the JSON APIsrc/components/ the board, the settings, shared interface partssrc/db/ schema, client, seedsrc/lib/ auth, ranks, grouping, value rules, eventse2e/ Playwright specsdrizzle/ generated migrationsThe clip and the board in the README
Section titled “The clip and the board in the README”docs/demo.gif shows an agent working a card, and docs/board.png is the
board as the seed makes it. One script takes both again, from a board that was
just seeded:
docker compose exec app npm run db:seednode scripts/record-demo.mjsRun the script on your machine, not in the container: it drives Playwright’s
Chromium and needs ffmpeg on the path (brew install ffmpeg). It plays the
person in the browser and the seeded agent over the API, and refuses a clip of
30 seconds or more, or of 8 MB or more. A take leaves the card with its agent,
so seed again before the next one. USHABTI_URL points it at another board.
Nothing in the tests or in CI runs it.
Branches
Section titled “Branches”There is one branch: main. There is no develop and there is no release
branch. A release is only an annotated tag on a main commit.
A release branch exists to fix an old version while main already holds
breaking changes. Ushabti supports one version at a time, so a release branch
would add work and give nothing back. If that ever changes, release/X.Y will
be cut from the tag.
A change from outside arrives through a pull request, and CI must pass before
it merges. The maintainer’s build loop lands on main directly, through the
admin bypass of the branch rules, after the same checks have passed on the
maintainer’s machine: format, lint, types and unit tests for each task, and
the whole Playwright suite on the production build before each push. CI runs
again on every push to main.
.setmaat/ is the configuration of that build loop. A contributor can ignore
it.
What runs, and when
Section titled “What runs, and when”| When | What runs |
|---|---|
| Pull request | Format, lint, types, unit tests, build, migrations, Playwright. The image is built, but only for amd64 and it is never pushed. |
Merge into main |
The same test job once, to prove the squashed commit still builds. Then CodeQL. No image. |
| Pull request close | The caches that pull request wrote, deleted. |
| Every night | The :edge image for amd64 and arm64, if main moved. |
| Every Sunday | Old caches and container versions no tag can reach, deleted. |
| Every Monday | CodeQL over main, to catch what a new query finds in old code. |
Tag vX.Y.Z |
The release image for amd64 and arm64, its provenance attestation, and the GitHub release. |
The rule behind the table: a pull request pays for correctness, a tag pays for publishing, and a merge pays for almost nothing, because the commits it carries passed minutes earlier. CodeQL sits on the merge rather than on the pull request. It takes about seventy seconds and has never held a change back, so making every pull request wait for it bought nothing.
Each architecture of the image builds on a runner of its own architecture. Do not put them back on one runner: arm64 under emulation took nine minutes where the pair now takes under three.
Making a release
Section titled “Making a release”Versions follow semantic versioning. Until 1.0.0 a minor bump may break something.
The usual way is the Release button of the Setmaat dashboard. It ships the
tasks in Ready: Setmaat’s release program pauses the loop, runs
.setmaat/e2e.sh, and shows the SQL of each new migration and waits for a yes.
Then .setmaat/release/tag.sh does steps 1 to 4 below and pushes main with
the tag. .setmaat/release/deploy.sh waits for the image on Docker Hub, backs
the database up and deploys. The tasks move to Shipped. Then
.setmaat/release/ship.sh ships the release on the board, so the same press
archives its tasks and writes its changelog entry. The bump is minor
unless the press names another. The release: block of .setmaat/project.yaml
holds the commands.
When Setmaat is not running, do it by hand:
- Move the entries under “Unreleased” in
CHANGELOG.mdinto a new## X.Y.Z — YYYY-MM-DDheading. - Set the same number in
package.json. - Commit both.
git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z- After the release, check every branch rebased across it. Its changelog entry must sit under “Unreleased”, not under the new version. The changelog merges by union, which never reports a conflict, so an entry can land under the new heading in silence.
The tag does the rest. It refuses to release if the tag, package.json and the
changelog disagree. When they agree it publishes:
jaklimoff/ushabti:X.Y.Z,:X.Y,:Xand:lateston Docker Hub and onghcr.io, for amd64 and arm64- a GitHub release whose notes are that section of the changelog
:edge is built once a night from main, and only if main moved. It is the
newest code, not a release. Use it to try something before it ships; do not
run it in production. docker.yml also has a manual trigger if you need an
:edge image sooner.
Licence of your work
Section titled “Licence of your work”You agree that your work goes into Ushabti under the MIT licence.