Skip to content

Development Setup

Prerequisites and instructions for building Stillwater from source.

Required Tools

Tool Version Purpose Install
Go 1.26.5+ Compiler and runtime https://go.dev/dl/
templ pinned in go.mod (tool directive) HTML template code generation No separate install; run via go tool templ generate
Tailwind CSS see ARG TAILWIND_VERSION in build/docker/Dockerfile CSS build (standalone CLI) See below
Git any Version control https://git-scm.com/

Optional Tools

Tool Purpose Install
Docker Container builds and testing https://docs.docker.com/get-docker/
golangci-lint Linting (make lint) https://golangci-lint.run/welcome/install/
Bruno API testing (collections in api/bruno/) https://www.usebruno.com/
air Hot reload during development (make dev) go install github.com/air-verse/air@latest
mermaid-cli (mmdc) Validate Mermaid diagrams in docs (pre-commit mermaid check) brew install mermaid-cli (or npm install -g @mermaid-js/mermaid-cli)
hadolint Lint Dockerfiles (make hadolint, pre-commit hadolint check) brew install hadolint
markdownlint-cli2 Lint Markdown (pre-commit markdownlint check; CI Docs job) brew install markdownlint-cli2 (or npx markdownlint-cli2)
prose-tooling Grammar/prose lint on staged Markdown/text (pre-commit prose-lint check) Local-only, central config repo at ~/Developer/prose-tooling (not a stillwater dependency); see its README for the LanguageTool server + .venv setup. Optional -- the hook skips gracefully if it is not present. Set PROSE_TOOLING_DIR to override the default checkout location.

Installing Tailwind CSS Standalone CLI

The project uses the Tailwind CSS standalone CLI (no Node.js required). The canonical version is ARG TAILWIND_VERSION in build/docker/Dockerfile. Download the matching binary for your platform from the GitHub releases page:

https://github.com/tailwindlabs/tailwindcss/releases

Replace <VERSION> in the commands below with the value of TAILWIND_VERSION from the Dockerfile (for example v4.2.0).

Linux:

curl -sLo tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/download/<VERSION>/tailwindcss-linux-x64
chmod +x tailwindcss
sudo mv tailwindcss /usr/local/bin/

macOS (Apple Silicon):

curl -sLo tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/download/<VERSION>/tailwindcss-macos-arm64
chmod +x tailwindcss
sudo mv tailwindcss /usr/local/bin/

Windows:

curl -Lo tailwindcss.exe https://github.com/tailwindlabs/tailwindcss/releases/download/<VERSION>/tailwindcss-windows-x64.exe
# Move to a directory on your PATH, or keep in the repo root (it is gitignored)

Clone and Build

git clone https://github.com/sydlexius/stillwater.git
cd stillwater

# Install Go dependencies
go mod download

# Generate templ code (converts .templ files to Go code)
# templ is pinned via the go.mod tool directive; invoke it with `go tool`.
go tool templ generate

# Build Tailwind CSS
tailwindcss -i web/static/css/input.css -o web/static/css/styles.css --minify

# Build the binary
go build -o stillwater ./cmd/stillwater

Quick Start with Make

If make is available, it wraps the common build, test, and tooling commands. The full target list below is generated from the Makefile's own ## target: help comments (the same source as make help), so it always reflects the current targets:

Command Description
make build Build the Go binary
make run Build and run locally
make dev Run with hot reload (requires air)
make test Run all tests with race detector and verbose output
make test-shuffle Run tests with random ordering to surface order-dependent tests (local hygiene; reproduce a failure with -shuffle=)
make test-race Run tests with race detector (native; CGO required for the race instrumentation)
make test-cover Run tests with coverage
make test-js Install JS dev dependencies and run Node.js unit tests for client-side JS modules.
make test-a11y Build an ephemeral server and run Playwright axe-core a11y smoke tests.
make lint Run golangci-lint
make hadolint Lint Dockerfile for best practices
make vulncheck Scan for known vulnerabilities (govulncheck, pinned to the CI version)
make fmt Format all Go and Templ files
make templ Generate Go code from Templ templates
make tailwind Build Tailwind CSS
make generate Run all code generation (templ + tailwind)
make generate-docs Regenerate docs site content from code (provider matrix, env-var reference, CLI reference, rules catalogue, settings reference, doc anchors, envelope-versions, make-command reference, platform-profiles, preferences reference, CI reference). Each generator enforces coverage: a new code-defined key without a desc: tag or doc entry fails the build.
make docs-serve Serve the docs site locally with live reload (requires properdocs)
make tailwind-watch Watch and rebuild Tailwind CSS
make migrate Run database migrations
make favicon Regenerate PNG favicons from logo design
make scan Build Docker image (no cache) and scan for CVEs (requires grype)
make audit Advisory local security pass (govulncheck + gosec + semgrep + syft/grype); govulncheck/grype gate, gosec/semgrep advisory-only
make docker-build Build Docker image
make docker-run Run Docker container
make docker-stop Stop Docker container
make check-openapi Verify OpenAPI spec matches handler implementations
make sync-tool-versions Mirror the CI-side Tailwind version into the Dockerfile pin
make hooks Install git hooks (pre-commit lint, pre-push gate)
make doctor Verify hook wiring without modifying anything
make worktree Create a sibling worktree with hooks wired and tracker row inserted into the Active table
make remove-worktree Remove a sibling worktree (via cleanup-worktree.sh) and delete its Active-table row
make clean Remove build artifacts
make uat Stage a UAT copy of the live DB + encryption key into ./.uat/ (siblings) and print the run command
make clean-uat Remove the staged ./.uat/ UAT copy (DB + key + run root)
make bruno-ci Build binary, run ephemeral server, execute Bruno API tests, clean up.
make help Show this help message

Running Locally

# Create the data directory
mkdir -p data

# Start with debug logging
SW_DB_PATH=./data/stillwater.db SW_LOG_FORMAT=text SW_LOG_LEVEL=debug ./stillwater

On Windows (MSYS2/Git Bash):

mkdir -p data
SW_DB_PATH=./data/stillwater.db SW_LOG_FORMAT=text SW_LOG_LEVEL=debug ./stillwater.exe

The app starts at http://localhost:1973. On first run it will run all database migrations and prompt you to create an admin account.

Running with Docker

# Build and run
docker compose up --build

# Or build the image separately
docker build -f build/docker/Dockerfile -t stillwater:dev .
docker run -p 1973:1973 -v stillwater-data:/config -v /path/to/music:/music:rw stillwater:dev

The Docker build handles templ generation implicitly (committed _templ.go files) and runs Tailwind CSS inside the build stage, so no local tooling beyond Docker is needed for container builds.

Development Workflow

Editing Code

  1. Modify Go files in internal/ or cmd/stillwater/
  2. Edit Templ templates in web/templates/ or web/components/
  3. Changes to .templ files require regeneration: go tool templ generate
  4. Generated _templ.go files should be committed alongside source templates
  5. Update CSS in web/static/css/input.css
  6. Rebuild: tailwindcss -i web/static/css/input.css -o web/static/css/styles.css --minify
  7. Add Go dependencies: go get (then commit changes to go.mod and go.sum)

Hot Reload (Development)

Use air for automatic rebuild on file changes:

go install github.com/air-verse/air@latest
air  # watches for changes and rebuilds

The app restarts at http://localhost:1973 after each rebuild.

Making Database Schema Changes

Stillwater uses a single migration file. To change the schema:

  1. Edit internal/database/migrations/001_initial_schema.sql directly
  2. Add or modify tables/columns in the appropriate -- +goose Up section
  3. Update the corresponding -- +goose Down section
  4. Test locally: go test ./internal/database/...
  5. Migrations run automatically on application startup via goose

Important (pre-GA only): Do not create new migration files. All schema changes go into 001_initial_schema.sql. After GA, standard goose versioned migrations (002, 003, ...) will be used for incremental schema changes against existing databases.

Code Quality

Before committing or opening a PR:

# Format code
make fmt          # or: go fmt ./... && go tool templ fmt web/

# Run linter
make lint         # or: golangci-lint run ./...

# Run tests
make test         # or: go test -race -count=1 ./...

Pre-commit hooks enforce formatting and linting automatically. Run make hooks to install the project hook; see .githooks/pre-commit for the full list of checks.

Signed Commits

main requires signed commits. Two checks enforce this, and they fail in opposite directions on purpose.

Locally, at commit time. .githooks/pre-commit runs scripts/check-commit-signing.sh, which refuses to create an unsigned commit. It verifies two separate things: that this clone is configured to sign (commit.gpgsign is true and user.signingkey is set), and that the configured signer actually works right now. The second part is a live probe: it signs a throwaway commit using this repository's resolved signing config and confirms the result carries a signature. An unreachable signer is reported as an error, never as a fallback to an unsigned commit. What it cannot do is see git commit --no-gpg-sign: that is argv on the commit invocation, and a pre-commit hook has no access to argv. The two layers below, not this one, are what cover that bypass.

Locally, immediately after. .githooks/post-commit runs the same script with --head, which reads the signature straight off the commit that was just created. This second pass is not redundant. git commit --no-gpg-sign is a command-line flag rather than config, and git does not expose it to hooks in any form, so a pre-commit hook sees commit.gpgsign as true, signs its probe successfully, prints PASS, and the commit it just approved is unsigned anyway. Disabling signing for one call is the mechanism behind #2624, so the pass that can see it is the one that matters. Note that git ignores a post-commit hook's exit status -- the commit already exists and cannot be un-made -- so this layer reports loudly rather than blocking. The fix is still free: the commit is local and unpushed, and git commit --amend -S --no-edit clears it.

The requirement itself comes from the tracked file .githooks/signed-commits-required. It is deliberately not inferred from commit.gpgsign, because a check that reads the requirement from the setting would conclude "signing is not required here" in exactly the case it exists to catch. Set SW_REQUIRE_SIGNED_COMMITS=0 to override.

In CI, as a backstop. .github/workflows/signed-commits.yml asks GitHub whether every commit in the PR is verified, and names any that are not. The local hooks are earlier and cheaper but advisory -- they can be skipped with --no-verify or never installed. The CI check runs where the committer has no say, so it is the layer that will actually hold once a maintainer registers Signed Commits as a required context in the Protect main ruleset. Until then it reports without blocking, and the thing that stops the merge is the ruleset's required_signatures rule rather than this check.

Fix an unsigned commit while it is still local. Once it is on a reviewed PR, the only remedy is rewriting shared history, which orphans any commit SHA cited in review replies.

Verifying a signature by hand. Check the raw commit object:

git cat-file commit HEAD | sed -n '1,/^$/p' | grep '^gpgsig'

Do not use git log --format=%G? for this. It reports N -- "no signature" -- for genuinely signed commits whenever gpg.ssh.allowedSignersFile is unset, because it answers "can I verify this signature", not "is there one". That is the default state on a fresh clone.

If signing fails. This repository signs through 1Password (gpg.ssh.program is op-ssh-sign), which reaches the desktop app over its own IPC. If signing breaks, confirm the 1Password app is running and unlocked. Note that op-ssh-sign does not use SSH_AUTH_SOCK -- that variable matters for pushing over SSH, not for signing. On a plain ssh-agent setup instead of 1Password, an empty SSH_AUTH_SOCK is the usual cause; non-interactive shells frequently get one. Export it and confirm with ssh-add -L.

Run bash scripts/test-check-commit-signing.sh to exercise the local check. Case 9 there guards a subtle constraint worth knowing if you modify the probe: git exports GIT_INDEX_FILE (and in a linked worktree, an absolute GIT_DIR) into its hooks, and those outrank git -C. Any git command the hook runs against another repository must first clear the inherited GIT_* environment, or it operates on the real index instead. Because this project works in worktrees, the contaminating case is the normal one here.

API Testing

Use Bruno collections in api/bruno/ to test endpoints:

  1. Install Bruno: https://www.usebruno.com/
  2. Open api/bruno/ as a collection
  3. Run requests against a local or running instance
  4. Bruno exports as plaintext files (no binary lock-in)

Local Bruno Coverage Recipe

To measure API coverage from the Bruno collection locally:

# 1. Build an instrumented binary
GOCOVERDIR=$(mktemp -d)
go build -cover -o /tmp/stillwater-cover ./cmd/stillwater

# 2. Start the server with coverage output enabled
GOCOVERDIR="$GOCOVERDIR" /tmp/stillwater-cover &
SERVER_PID=$!

# 3. Run the Bruno collection (from api/bruno/)
cd api/bruno
bru run --env ci --env-var "baseUrl=http://127.0.0.1:1973" --disable-cookies -r .

# 4. Stop server gracefully (flushes coverage counters)
kill "$SERVER_PID" && wait "$SERVER_PID" 2>/dev/null || true

# 5. Convert binary coverage to a Go coverage profile
go tool covdata textfmt -i="$GOCOVERDIR" -o bruno-coverage.out

# 6. View the report
go tool cover -func=bruno-coverage.out | tail -1

Running Tests

# All tests
go test -v -count=1 ./...

# With race detector (requires CGO, not available on MSYS_NT/Windows without GCC)
go test -v -race -count=1 ./...

# Single package
go test -v -count=1 ./internal/image/...

Environment Variables

The full, generated reference for every SW_ environment variable is on the published docs site:

Reference: Environment variables

That page is generated from the configuration definition (internal/config/config.go) via make generate-docs and is always up to date. Do not maintain a separate table here.

For the Docker container, two additional variables control file ownership:

Variable Default Description
PUID 99 User ID the container process runs as. Set to your host UID to avoid permission issues on mounted volumes.
PGID 100 Group ID the container process runs as. Set to your host GID.

Project Structure

See CLAUDE.md for the full architecture overview and coding conventions.

Releasing

Releases are automated with GoReleaser. Pushing a semver tag triggers the release workflow, which builds multi-platform binaries, pushes Docker images to GHCR with semver tags, and creates a GitHub Release with auto-generated notes from conventional commit history.

Tag and push

Tags must be signed annotated tags (-s) to earn the GitHub Verified badge. This requires a GPG or SSH signing key configured in your git config.

git tag -s v0.2.0 -m "Release v0.2.0"
git push origin v0.2.0

The release workflow creates the GitHub Release automatically. Pre-release tags (e.g. v0.2.0-rc1) are marked as pre-release on GitHub.

Local dry run (no publish)

goreleaser release --snapshot --clean

This builds all artifacts locally without creating a release or pushing images. Useful for verifying the config before tagging.

Validate config

goreleaser check