Contributor conventions
Follow the repository rules for generated code, migrations, testing, and documentation.
On this page
Single-binary Go app: deploy Bash, PowerShell, and Python scripts against environments with live SSE logs, using server containers or remote agents. Stack: Go 1.26 + chi + SQLite (modernc.org/sqlite, pure Go, no CGO), PostgreSQL, or SQL Server + sqlc + goose + templ + HTMX + Alpine + Tailwind/DaisyUI.
Dependency licenses
All packages used by this project must be open source. Prefer permissive licenses such as MIT, BSD, ISC, or Apache-2.0. Check the license of the exact version before adding or upgrading a dependency, including its transitive dependencies. Do not add proprietary, source-available, revenue-limited, or commercial-license-only packages. Keep required license notices with bundled assets.
Critical build gotcha
*_templ.go are gitignored (see .gitignore). A fresh checkout cannot go vet/go test/go build until templ runs:
templ generate # required before any go command on a fresh checkout
make swagger-ui-copy # installs locked npm dependencies and copies embedded UI assets
make build # generates templates, CSS/JS, and Swagger UI, then builds Go
CI generates templ output before checks that import the application. Mirror that for local go vet/go test/go build after pulling or after editing views/*.templ. make templ-generate uses the pinned Go tool.
static/swagger-ui/ is also gitignored (generated by make swagger-ui-copy from node_modules/swagger-ui-dist). The //go:embed swagger-ui pattern in static/static.go fails on a fresh checkout with pattern swagger-ui: no matching files found. The Dockerfile and CI jobs that build the application materialize it; mirror that locally with make swagger-ui-copy before any go vet/go test/go build on a fresh clone. This target requires npm.
Generated code — do not hand-edit
- Generated
internal/db/*.sql.go,models.go, anddb.goare sqlc output; tests in that directory are hand-written. Editqueries/*.sql, thensqlc generate(config insqlc.yaml).internal/repository/repository.gois hand-written and wraps*db.Queries. *_templ.gois templ output. Editviews/*.templ, runtempl generate.static/css/tailwind.min.css,static/js/app.bundle.js, andstatic/js/charts.bundle.jsare built bymake tailwind-build/make js-buildand are committed, along with their generated license notices. Once templ and Swagger UI output exists,go buildworks without npm. Only regenerate bundles when CSS/JS source changes.static/swagger-ui/*is built bymake swagger-ui-copyfromnode_modules/swagger-ui-distand is gitignored. Never hand-edit those files.
Migrations
migrations/*.sql are goose-formatted (-- +goose Up / -- +goose Down) and embedded via migrations/embed.go. SQL Server uses its separate migrations/mssql/ sequence. Choose the next number in each current sequence rather than reusing an existing migration number. Migrations auto-run on startup via internal/migrate/migrate.Run — no manual step. SQLite tests use :memory: + migrate.Run; backend parity tests use PostgreSQL or SQL Server fixtures.
Running / testing
For a coding thread, read isolated development stacks.
Run make dev-stack-thread from the thread’s worktree.
Use its connection record for API and browser tests.
Use the worktree’s .local/dev-stack.json registration to find that record.
Import the scripts from t3.json into the T3 project.
The runOnSettle script deletes the registered stack and database when the thread settles.
Keep the thread unsettled during manual review.
Use separate browser profiles and scoped certificate trust.
Do not register retained regression fixtures with the thread wrapper.
If cleanup fails, keep the registration and retry make dev-stack-thread ACTION=settle.
For populated demos, follow the demo lifecycle.
Run make demo, with the selected engine’s local Unix socket active first.
On Citadel, use systemctl --user start podman.socket. Verify agent/3, the
selected runtime, container mode, and Bash/Python/PowerShell container execution
from the retained deployment and agent-capabilities.json before reporting readiness.
Return the URL, generated admin@durp.info login, data directory, certificate
path, and stop command. Leave the server, agent, and proxy running.
- Server/template/asset changes:
make demo-refresh DEMO_DIR=/absolute/demo/path. Finish script edits first. Never edit a Bash script while it is running. Refresh preserves the demo settings and refuses unfinished or unconfirmed work. Inspect its logs and fix failures before reporting readiness. - Database, agent, or startup configuration changes:
make demo-refresh-full DEMO_DIR=/absolute/old/demo/path. Return the new details; old deployment history stays on disk. - Teardown: wait for completion or API/UI cancellation with confirmed cleanup,
then
make demo-stop DEMO_DIR=/absolute/demo/path. Keep data and identities. Do not stop the shared engine/socket, another demo, or remove state to clear warnings.
The supervisor receives the operator’s runtime socket; container-mode workloads receive none. Host-mode scripts inherit the operator’s runtime authority. Use trusted scripts. Do not change host socket permissions. Follow the linked runbook for rootless Podman mapping, refresh barriers, rollback, and firewall leases.
All applicable Go and end-to-end tests must pass before work is complete. Run the full relevant suites only at final verification; use focused tests while working. Do not treat focused tests as a substitute for final verification.
Test-owned database fixtures must register testutil.CleanupContainer(t, container)
immediately after creation, before checking its error: Testcontainers can
return a live container together with a startup error. Cleanup uses its own
bounded context and fails the test if removal fails. Use make test or
bash scripts/go_test.sh [go test arguments] for local Go tests. The runner
assigns each package binary a separate Testcontainers session. It removes only
owned sessions on success, failure, timeout, or HUP/INT/TERM. Cleanup also runs
when Ryuk is disabled on rootless Podman. Plain
go test relies on Ryuk for process-level cleanup; SIGKILL or machine failure
cannot run shell traps. Running-server E2E examples and demos remain retained.
For package-owned fixtures, register each container with the package owner before checking its startup error.
Remove package-owned containers in TestMain with testutil.TerminateContainer.
Fail the package if removal fails.
Give each test a fresh database and close its connections before database removal.
Every scenario in make e2e-test must remain available for manual checks
after the command exits. Retain its projects, environments, release snapshots,
deployment history, templates, and test accounts. For destructive checks,
retain or recreate a disposable example. Print links to the retained examples
and keep docs/manual-e2e.md current with repeat steps and expected results.
Schedules must remain disabled after testing; document how to enable them.
Pending approvals can expire; retain the release so a fresh plan can be made.
This rule applies to the running-server suite, not isolated test databases.
Every new or changed functionality must be available through a corresponding public API endpoint and have end-to-end coverage through that API. Validation is not complete until the API contract and its E2E test both pass. New or changed web functionality must also have a web E2E test and pass it before validation is complete.
skills/durpdeploy/SKILL.md is the shipped agent skill documenting how
external agents operate a running instance (served publicly at
/.well-known/skills/ and /.well-known/agent-skills/). When API behavior
documented there changes, update that file in the same change.
./durpdeploy # defaults to :8080 and durpdeploy.db in CWD; requires a secret key
bash scripts/go_test.sh -v -count=1 -timeout=20m ./...
bash scripts/go_test.sh -run TestName ./internal/handler/...
./scripts/e2e_test.sh # bash end-to-end: builds, runs server, curl happy/cancel/validation paths (~10s+)
GitLab merge request checks run lint before Go tests and E2E checks.
Run make lint for the pinned golangci-lint checks across packages and tests.
The target generates required assets and installs a checksum-verified tool in bin/lint/.
CI also requires go vet ./..., clean gofmt and golines output, and hadolint.
Other security, browser, and backend checks run in separate jobs.
Image/chart publication runs on protected pushes or manual pipelines after its dependencies.
See docs/gitlab-ci.md for the AMD64 Kubernetes runner and credential setup.
SonarCloud and Codex
SonarCloud and the main Go/E2E suites run on merge requests.
Security scans also run on main pushes, tags, configured schedules, and manual pipelines.
The targeted API-token test runs on merge requests.
SonarCloud reports Go coverage and waits for the Quality Gate.
A failed gate fails the GitLab pipeline.
Before declaring merge request work complete, inspect SonarCloud and address valid new-code findings.
With a local SONAR_TOKEN, use:
make sonar-issues MR=<merge-request-number>
The command is read-only and must never print or persist SONAR_TOKEN.
SonarCloud does not replace the required Go and end-to-end verification above.
Conventions agents get wrong
-
Pre-commit: run
make install-hooksonce per clone. It copiesscripts/pre-committo the repository’s common Git directory underhooks/pre-commit, shared by linked worktrees. It preserves globalcore.hooksPathdispatchers, which must forward project hooks to enforce this check. The hook runsgolines -m 80 --ignore-generated -l .on staged Go files and blocks the commit if any line exceeds 80 cols. The Makefile targetgolines-checkdoes the same dry-run for the whole tree. CI re-runs the same check in thelintstage, so--no-verifydoesn’t bypass the gate. -
Add routes in
internal/server/server.goonly. All chi routes are registered there; handlers live ininternal/handler/*. -
Use migrated test schemas.
internal/handler/logs_test.gousesmigrate.Run, as doesinternal/db/smoke_test.go. Do not duplicate the schema inline. -
DSN is configurable through
DURPDEPLOY_DBincmd/server/main.go; default:durpdeploy.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000).DURPDEPLOY_ADDRoverrides the default:8080listener. Database file, WAL, and SHM are gitignored. -
Deployment execution uses sequential steps, with server steps in Docker/Podman containers and remote steps dispatched to matching agents. Bash, PowerShell, and Python are supported when available in the selected runtime. Remote fan-out can execute a step on multiple agents.
internal/runner/runner.gostreams logs throughLogBroker(SSE) and tracks cancels by deployment ID. Auth is at the HTTP boundary. Server steps require a container image and have no host fallback; seedocs/deploy.mdanddocs/agents.mdfor execution boundaries. -
Releases snapshot steps and variables (
steps_jsonandrelease_variables); later project edits do not change them automatically. Refresh re-snapshots for future deployments; existing deployment-step snapshots remain frozen. -
Templ tags render to HTMX swaps; handlers return 303 for POST redirects, 422 for validation failures (see
scripts/e2e_test.shfor the contract).
Viewer role — UI gating pattern
Viewers (role = "viewer") are read-only. Defense is two layers, both required:
- CSRF middleware (
internal/auth/csrf.go) — rejects application writes from a viewer at the protocol layer. Logout and the viewer’s own security settings remain allowed with CSRF protection; session-authenticated/api/lintis read-only and exempt.- HTMX requests: returns
200+HX-Triggercarrying amakeToastevent (so the same red toast the rest of the app uses fires, and the page stays put — no error overlay, no full reload). - Non-HTMX form submits: returns
403+ a self-contained styled page (back link, no broken browser error screen).
- HTMX requests: returns
- CanWrite templ guard — hides write affordances so viewers never have a reason to hit the middleware.
- Helper:
pages.CanWrite(ctx)(and the parallelcomponents.canWrite(ctx)for the components package). Returnsfalsefor viewers. - Buttons: wrap each write affordance in
if CanWrite(ctx) { <button>...</button> }. Affects New / Edit / Delete / Reorder / Save Template / Approve / Cancel / Re-run / Toggle buttons across every page. - Form pages: wrap the entire form in
if CanWrite(ctx) { @FormBody(...) } else { @ViewerForbiddenMessage(...) }. TheViewerForbiddenMessageis inviews/pages/permissions.templand takes(title, action, what, backHref, currentPath).
- Helper:
Apply both layers even on admin/project-gated routes: middleware enforces writes, and templ guards prevent dead-end forms. See roles for permissions.
Pattern for new form pages: the UserFormPage, ProjectFormPage, EnvironmentForm, LifecycleFormPage, TemplateForm, ScheduledDeploymentFormPage, DeployFormPage, and the StepForm/StepEditRow components are the reference. Each follows the same shape: outer if !CanWrite(ctx) { @ViewerForbiddenMessage(...) } else { @Base + @FormBody }.
Tests: TestAuth_ViewerHTMXReturnsToast (HTMX toast path), TestAuth_ViewerCannotPost (non-HTMX 403 path), TestUsers_ViewerSeesForbiddenOnFormPages (templ guard renders the message on /environments/new, /lifecycles/new, /templates/new).
Security model
The multi-user model is not optional. Session-authenticated browser routes in internal/server/server.go use this protected-group middleware order:
auth.AuthMiddleware(repo)— session cookie → user in context. Redirects to/loginon miss.webRequestBodyLimit— bounds and validates forms before CSRF parsing.auth.CSRFMiddleware()— checks CSRF tokens and blocks viewer application writes, with the exceptions described above. Viewer rejections are surfaced as a toast (HTMX) or a styled 403 page (non-HTMX).audit.Middleware(repo)— records successful state changes unless the handler suppresses or records them directly. This middleware does not audit CSRF rejections or 4xx responses; authentication handlers can emit separate security audit events.
The /api/v1 group instead uses bearer-token authentication, WriteBlockMiddleware, api.RequestBodyLimit, and audit middleware. The separate mTLS agent listener uses agent authentication and claim checks. Project and admin gates apply inside the corresponding route groups.
New state-changing routes must be added to the actionMap in internal/audit/routes.go so the audit log gets a stable action name. The fallback heuristic (method + first path segment) is lossy.
Use docs/roles.md for current project and global authorization, and docs/security.md for execution boundaries. Agent host-mode scripts run with the agent operator’s authority; server and agent containers share the runtime host’s kernel. Trust scripts accordingly.
The server owner is responsible for deployment scripts and their effects. Staging cleanup covers managed staging and normal job descendants. Deliberate supervisor sabotage and script-created copies outside managed staging are outside this cleanup guarantee. Do not add per-job cgroups or cgroup delegation requirements for staging transfer. Keep polling blocked when process or staging cleanup cannot be confirmed.
Do not “simplify” by: removing the CSRF check, inlining VerifyPassword, replacing argon2id with bcrypt/SHA, putting the session token in a URL parameter, skipping the audit_log insert on a new route, or merging audit.Record into a handler without going through the middleware. Each of these is a documented attack vector in docs/attack-drill.md.
UI design — table layout conventions
All pages with data tables must follow this pattern. Tables scale with the browser window and have consistent column widths across pages.
Table base class: table table-zebra table-fixed w-full
table-fixedmakes columns respect percentage widths (without it, browser auto-sizes to content)w-fullmakes the table fill its container- Custom CSS in
static/css/input.cssenforceswidth: 100% !important; table-layout: fixedon all.tableelements
Column width pattern — use percentage widths on <th> so columns scale with window:
| Columns | Pattern |
|---|---|
| 3 cols | w-1/4 + w-auto + w-1/4 (or fixed for last) |
| 4 cols | w-1/5 + w-2/5 + w-1/5 + w-1/5 |
| 6 cols | w-1/6 × 6 |
| Steps (Order, Name, Script) | w-16 + w-1/5 + w-auto |
| Steps (Order, Name, Script, Actions) | w-16 + w-1/5 + w-auto + w-96 |
Actions column: w-96 (24rem) for tables with 5 buttons (↑, ↓, Edit, Save Template, Delete), w-48 (12rem) for tables with 2 buttons.
Cell overflow: Add truncate to <td> cells with text content. Add whitespace-nowrap to date/timestamp cells. Buttons in actions cells use whitespace-nowrap to stay in one row.
Layout container: Use w-full px-4 sm:px-6 lg:px-8 (no max-w-screen-* — tables fill the full viewport). Never use Tailwind’s container class for the main content area.
Button text in tables: Keep short to fit one row. “Save as Template” → “Save Template”. Use btn-xs for table row buttons.
Ponytail (lazy senior) rules — active by default
Lazy = efficient, not careless. Read the whole flow first, then pick the highest rung that holds:
- Does this need to exist at all? Speculative need = skip it, say so in one line. (YAGNI)
- Already in this codebase? Reuse the helper/util/type/pattern a few files over. Re-implementing what’s here is the most common slop.
- Stdlib does it? Use it. (
database/sql.NullString,slog,embed,os/exec,net/http) - Native platform feature covers it? DB constraint over app code; chi middleware over hand-rolled; HTMX attributes over JS.
- Already-installed dependency solves it? Use it. Never add a new dep for what a few lines can do.
- Can it be one line? One line.
- Only then: the minimum code that works.
Rules:
- No unrequested abstractions: no interface with one impl, no factory for one product, no config for a value that never changes.
- No boilerplate, no scaffolding “for later” — later can scaffold for itself.
- Deletion over addition. Boring over clever (clever is what someone decodes at 3am).
- Fewest files possible. Shortest working diff wins — but only after understanding the problem. The smallest change in the wrong place isn’t lazy, it’s a second bug.
- Bug fix = root cause, not symptom. Before editing, grep every caller of the function. One guard in the shared function beats a guard in every caller; patching only the path named in the ticket leaves siblings broken.
- Mark deliberate simplifications with a
// ponytail:comment naming the ceiling and upgrade path, e.g.// ponytail: global lock, per-deployment locks if throughput matters. - Complex request? Ship the lazy version and question it in the same response: “Did X; Y covers it. Need full X? Say so.” Never stall on an answer you can default.
- Two stdlib options, same size? Take the one correct on edge cases. Lazy = less code, not flimsier algorithm.
- Output: code first, then at most three short lines — what was skipped, when to add it. No essays, no feature tours.
Never lazy away: input validation at trust boundaries (HTTP handlers, scripts/e2e_test.sh contract), error handling that prevents data loss (release snapshots, deployment cancel), security, accessibility basics, anything explicitly requested.
Never lazy about understanding the problem — the ladder shortens the solution, never the reading. Trace the whole flow end to end first, then climb.
Documentation
Use Simplified Technical English for new and changed documentation. Code, commands, identifiers, paths, and UI labels retain their exact spelling.
- Use American English spelling.
- Use the active voice when the actor is important.
- Write one instruction in each sentence.
- Start an instruction with an imperative verb.
- Put a condition before the instruction that it controls.
- Use no more than 20 words in an instruction.
- Use no more than 25 words in a descriptive sentence.
- Use one topic in each paragraph.
- Use no more than six sentences in a paragraph.
- Do not use contractions or semicolons.
- Use the same term for the same item or operation.
- Do not change commands, code, identifiers, or user-interface text.