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¶
- Modify Go files in
internal/orcmd/stillwater/ - Edit Templ templates in
web/templates/orweb/components/ - Changes to
.templfiles require regeneration:go tool templ generate - Generated
_templ.gofiles should be committed alongside source templates - Update CSS in
web/static/css/input.css - Rebuild:
tailwindcss -i web/static/css/input.css -o web/static/css/styles.css --minify - Add Go dependencies:
go get(then commit changes togo.modandgo.sum)
Hot Reload (Development)¶
Use air for automatic rebuild on file changes:
The app restarts at http://localhost:1973 after each rebuild.
Making Database Schema Changes¶
Stillwater uses a single migration file. To change the schema:
- Edit
internal/database/migrations/001_initial_schema.sqldirectly - Add or modify tables/columns in the appropriate
-- +goose Upsection - Update the corresponding
-- +goose Downsection - Test locally:
go test ./internal/database/... - 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:
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:
- Install Bruno: https://www.usebruno.com/
- Open
api/bruno/as a collection - Run requests against a local or running instance
- 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.
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)¶
This builds all artifacts locally without creating a release or pushing images. Useful for verifying the config before tagging.