Contributing
This page covers what to know before opening a PR: how to run things locally, what commits look like, and how releases get cut.
Local development
Prerequisites
- Node.js 20+ (or 22 — both work).
- pnpm 10+ (
corepack enableis the easiest path). - Docker for the Postgres container.
- ffmpeg, audiowaveform, and the
obscura-phashGo binary onPATHif you want to exercise media work outside Docker. The unified Docker image bundles these so most work happens in the container.
Two ways to run
The container way (recommended for most changes):
docker compose -f infra/docker/docker-compose.yml up
This brings up Postgres, the SvelteKit web app, and the worker with hot reload. Web is at http://localhost:8008.
The local way (when you're iterating fast on the SvelteKit app):
# 1. Start Postgres (or any Postgres you control)
docker run --rm -d --name obscura-pg \
-e POSTGRES_USER=obscura -e POSTGRES_PASSWORD=obscura -e POSTGRES_DB=obscura \
-p 5432:5432 postgres:16-alpine
# 2. Set the env
export DATABASE_URL=postgres://obscura:obscura@localhost:5432/obscura
# 3. Migrate
pnpm --filter @obscura/db db:migrate
# 4. Run web + worker in parallel
pnpm dev
Web is at http://localhost:8008; the worker logs to the same terminal via turbo's interleaved output.
Useful filters
pnpm --filter @obscura/web-svelte dev # web only
pnpm --filter @obscura/worker dev # worker only
pnpm --filter @obscura/db db:generate # diff schema → new SQL migration
pnpm --filter @obscura/db db:migrate # apply pending migrations
pnpm docs:dev # this site
The commit policy
The CLAUDE.md "Commit & Changelog Policy" is the source of truth; the highlights:
- Commit after every set of changes. Don't batch; small reviewable commits are the norm.
- Update
CHANGELOG.mdwith every commit under## [Unreleased], grouped by Keep a Changelog sections (Added/Changed/Fixed/Removed/Docs). - Don't bump
package.jsonversions on regular commits. The Release workflow does that.
Suggested commit style:
chore: bootstrap workspace
docs: define repo contract
feat(web): add media library shell
feat(api): add health and jobs routes
fix(worker): stabilize queue startup
chore(release): … is reserved for the Release workflow. Don't write one by hand.
Changelog rules
Every release section (including ## [Unreleased]) must start with a ### What's New block aimed at users — non-technical, 1–2 sentences per bullet, only user-visible changes (features, major fixes, behavioral changes the user would notice).
The standard sections (### Added / ### Changed / ### Fixed / ### Removed / ### Docs) follow with the detailed dev-facing entries. If your change has user-visible impact, add a What's New bullet and a detailed entry. Internal refactors only get the detailed entry.
Entries should be written for users of the app:
- Good: "Subtitles now auto-enable when a preferred-language track is found on play."
- Bad: "Refactor
subtitleAutoEnable()to read settings via the new resolver helper."
Versioning
Obscura follows SemVer and Keep a Changelog.
| Bump | Means |
|---|---|
| MAJOR | Breaking API, schema needs manual migration, config format changed. |
| MINOR | New features, new endpoints, new UI views. |
| PATCH | Bug fixes, UI tweaks, dep updates, docs. |
Between releases, package.json carries a pre-release marker (0.21.0-dev), meaning "the next release will be at least 0.21.0". Dev Docker images are tagged dev and <version>-<short-sha>.
Release versions are plain X.Y.Z. They always have a matching ## [X.Y.Z] - YYYY-MM-DD heading in CHANGELOG.md. Git tags are vX.Y.Z.
The Dockerfile runs pnpm release:check on every build, and pnpm release:check --release on release builds (via the RELEASE_STRICT=1 build arg). That check enforces a CHANGELOG.md heading exists for the current package.json version.
How a release happens
Releases are cut server-side by the GitHub Actions Release workflow. Don't hand-edit versions, hand-edit CHANGELOG release headings, or push release tags manually.
- Make sure
mainis green and## [Unreleased]has the entries you want shipped — these become the GitHub Release body verbatim. - Open the repo on GitHub → Actions → Release → Run workflow.
- Pick the bump (
patch/minor/major) or paste an explicitX.Y.Z. - The workflow:
- Runs
scripts/release/cut.mjs --phase releaseonmain. Bumps everypackage.jsontoX.Y.Z. Converts## [Unreleased]to## [X.Y.Z] - YYYY-MM-DDand writesRELEASE_NOTES.md. - Creates commit
chore(release): vX.Y.Zand tagvX.Y.Z. - Runs
scripts/release/cut.mjs --phase postto bump everypackage.jsontoX.Y.(Z+1)-devand commitschore(release): begin vX.Y.(Z+1)-dev cycle. - Pushes both commits and the tag.
- Checks out the release tag and builds the unified Docker image with
RELEASE_STRICT=1. Pushes to GHCR aslatest,X.Y.Z,X.Y, andX. - Creates a GitHub Release for
vX.Y.ZwithRELEASE_NOTES.mdas the body.
- Runs
- The dev-image workflow skips
chore(release):commits, so the release commit and post-bump commit don't double-trigger a dev build.
If the release workflow fails partway through, the tag may already exist. Delete the tag (git push --delete origin vX.Y.Z) before retrying — only do this when you're certain the release image and GitHub Release have not been published.
CI/CD
Two automation surfaces, both in .github/workflows/:
publish-dev.yml— runs on every push tomain(exceptchore(release):commits). Builds the unified image withRELEASE_STRICT=0, publishes:dev— the latestmaincommitsha-<short>— pinned per commit<version>-<short>— e.g.0.21.0-abc1234, where<version>is the current-devmarker
release.yml— manualworkflow_dispatchonly. The flow described above.validate.yml— runs lint, typecheck, unit tests, integration tests on PR + push tomain+ nightly. Optional smoke e2e.documentation-site.yml— builds and deploys this site to GitHub Pages.
latest always resolves to the most recent released version. dev is bleeding edge with rollback safety via sha- tags.
Style and quality
- TypeScript across apps and packages. No JS in new code.
- Prefer typed contracts in
@obscura/contractsover ad-hoc object shapes. - Add tests with new logic when behavior can regress. Unit tests for pure functions, integration tests for DB-dependent paths.
- Keep app boundaries explicit: UI + HTTP in
apps/web-svelte, heavy work inapps/worker, shared logic inpackages/*. - Don't introduce abstractions beyond what the task requires. A bug fix doesn't need surrounding cleanup. Three similar lines is better than a premature abstraction.
- Don't add error handling for scenarios that can't happen. Trust internal code; only validate at system boundaries.
Git hygiene
- Avoid destructive git commands unless explicitly necessary.
- Don't skip hooks with
--no-verify. - Don't amend published commits. Add a follow-up commit instead.
- Open a PR for non-trivial work; let CI run before merging.
Reading order
If you're new and want to make a small change, this order tends to work:
- Run it locally with Docker compose. Click around. Open dev tools.
- Read Architecture and Monorepo Layout.
- Find a
+server.tsor processor that does something close to your task. - Trace the call chain: handler → app-core → db.
- Make the change. Add a test. Update CHANGELOG. Commit.