diff --git a/.golangci.yml b/.golangci.yml
index 26b1610..a7a74c2 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -10,14 +10,20 @@ run:
linters:
default: all
+ enable:
+ # Successor to the deprecated gomodguard. Named explicitly, rather than
+ # left to `default: all`, because it carries the module policy below.
+ - gomodguard_v2
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- - depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
+ # Deprecated: the warning is attached to the old name, so it is
+ # silenced by disabling that name, not by enabling the successor.
+ - wsl # Deprecated, replaced by wsl_v5
+ - gomodguard # Deprecated, replaced by gomodguard_v2
settings:
lll:
line-length: 88
@@ -28,6 +34,64 @@ linters:
max-complexity: 15
dupl:
threshold: 100
+ depguard:
+ # Test-support code must not be compiled into the shipped binary. A
+ # test-support package exists to hand a test privileges the program
+ # itself must never have, so a file that is not a test must not import
+ # one. Test files, and the files inside a package whose directory name
+ # ends in `test`, are where that code belongs, and are exempt.
+ #
+ # The deny list below is the one part of this file a repository is
+ # expected to extend, and the only part it may. depguard matches an
+ # import path against a list of prefixes, so it cannot be told "any path
+ # whose last segment ends in test"; a repository's own test-support
+ # packages have to be named here one at a time, by full import path,
+ # under a module path that differs from repository to repository. Add
+ # them; change nothing else.
+ rules:
+ test-support:
+ list-mode: lax
+ files:
+ - "$all"
+ - "!$test"
+ - "!**/*test/**"
+ deny:
+ - pkg: net/http/httptest
+ desc: >-
+ Test-support code belongs in test files and in packages whose
+ directory name ends in test, not in the shipped binary.
+ # Only decisions already recorded in the Go package defaults are
+ # listed here. Every entry matches the module path exactly.
+ gomodguard_v2:
+ blocked:
+ - module: github.com/rs/zerolog
+ recommendations:
+ - log/slog
+ reason: "Structured logging is stdlib log/slog."
+ # One entry per pre-fork module path, because the later releases
+ # are separate paths. A prefix match would be shorter but would
+ # also reach github.com/go-redis/redismock, the test double for
+ # the successor these entries recommend.
+ - module: github.com/go-redis/redis
+ recommendations:
+ - github.com/redis/go-redis/v9
+ reason: "Pre-fork module; use the maintained go-redis v9."
+ - module: github.com/go-redis/redis/v7
+ recommendations:
+ - github.com/redis/go-redis/v9
+ reason: "Pre-fork module; use the maintained go-redis v9."
+ - module: github.com/go-redis/redis/v8
+ recommendations:
+ - github.com/redis/go-redis/v9
+ reason: "Pre-fork module; use the maintained go-redis v9."
+ - module: github.com/sergi/go-diff
+ recommendations:
+ - github.com/aymanbagabas/go-udiff
+ reason: "No unified diff output; use go-udiff."
+ - module: github.com/hexops/gotextdiff
+ recommendations:
+ - github.com/aymanbagabas/go-udiff
+ reason: "Unmaintained fork; use go-udiff."
issues:
max-issues-per-linter: 0
diff --git a/.prettierignore b/.prettierignore
index d87c685..c9cb794 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,2 +1,5 @@
+node_modules/
+yarn.lock
+
# Vendored, minified third-party bundles must never be reformatted.
*.min.js
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..8af31cd
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "tabWidth": 4,
+ "proseWrap": "always"
+}
diff --git a/CONVENTIONS.md b/CONVENTIONS.md
index 6f7c217..53bc824 100644
--- a/CONVENTIONS.md
+++ b/CONVENTIONS.md
@@ -1,6 +1,8 @@
# Go HTTP Server Conventions
-This document defines the architectural patterns, design decisions, and conventions for building Go HTTP servers. All new projects must follow these standards.
+This document defines the architectural patterns, design decisions, and
+conventions for building Go HTTP servers. All new projects must follow these
+standards.
## Table of Contents
@@ -25,18 +27,18 @@ This document defines the architectural patterns, design decisions, and conventi
These libraries are **mandatory** for all new projects:
-| Purpose | Library | Import Path |
-|---------|---------|-------------|
-| Dependency Injection | Uber fx | `go.uber.org/fx` |
-| HTTP Router | go-chi | `github.com/go-chi/chi` |
-| Logging | slog (stdlib) | `log/slog` |
-| Configuration | Viper | `github.com/spf13/viper` |
-| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` |
-| CORS | go-chi/cors | `github.com/go-chi/cors` |
-| Error Reporting | Sentry | `github.com/getsentry/sentry-go` |
-| Metrics | Prometheus | `github.com/prometheus/client_golang` |
-| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` |
-| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` |
+| Purpose | Library | Import Path |
+| -------------------- | --------------- | ------------------------------------- |
+| Dependency Injection | Uber fx | `go.uber.org/fx` |
+| HTTP Router | go-chi | `github.com/go-chi/chi` |
+| Logging | slog (stdlib) | `log/slog` |
+| Configuration | Viper | `github.com/spf13/viper` |
+| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` |
+| CORS | go-chi/cors | `github.com/go-chi/cors` |
+| Error Reporting | Sentry | `github.com/getsentry/sentry-go` |
+| Metrics | Prometheus | `github.com/prometheus/client_golang` |
+| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` |
+| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` |
---
@@ -85,7 +87,8 @@ project-root/
### Key Principles
- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping.
-- **`internal/`**: All application packages. Not importable by external projects.
+- **`internal/`**: All application packages. Not importable by external
+ projects.
- **One package per concern**: config, database, handlers, middleware, etc.
- **Flat handler files**: One file per handler or logical group of handlers.
@@ -190,7 +193,8 @@ Providers are resolved automatically by fx, but conceptually follow this order:
2. `logger.New` - Logger (depends on Globals)
3. `config.New` - Configuration (depends on Globals, Logger)
4. `database.New` - Database (depends on Logger, Config)
-5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, Database)
+5. `healthcheck.New` - Health check (depends on Globals, Config, Logger,
+ Database)
6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
8. `server.New` - Server (depends on all above)
@@ -453,7 +457,8 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
### Closure-Based Handler Pattern
-All handlers return `http.HandlerFunc` using the closure pattern. This allows initialization logic to run once when the handler is created:
+All handlers return `http.HandlerFunc` using the closure pattern. This allows
+initialization logic to run once when the handler is created:
```go
// internal/handlers/index.go
@@ -510,7 +515,8 @@ func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interfac
### Handler Naming Convention
- `HandleIndex()` - Main page
-- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix
+- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method
+ suffix
- `HandleNow()` - API endpoints
- `HandleHealthCheck()` - System endpoints
@@ -733,7 +739,8 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
1. **Environment variables** (highest priority via `AutomaticEnv()`)
2. **`.env` file** (loaded via `godotenv/autoload` import)
-3. **Config files**: `/etc/{appname}/{appname}.yaml`, `~/.config/{appname}/{appname}.yaml`
+3. **Config files**: `/etc/{appname}/{appname}.yaml`,
+ `~/.config/{appname}/{appname}.yaml`
4. **Defaults** (lowest priority)
### Environment Loading
@@ -1005,6 +1012,7 @@ var Static embed.FS
```
Directory structure:
+
```
static/
├── static.go
@@ -1045,15 +1053,13 @@ Templates use Go's template composition:
```html
-{{ template "htmlheader.html" . }}
-{{ template "navbar.html" . }}
+{{ template "htmlheader.html" . }} {{ template "navbar.html" . }}
-{{ template "pagefooter.html" . }}
-{{ template "htmlfooter.html" . }}
+{{ template "pagefooter.html" . }} {{ template "htmlfooter.html" . }}
```
### Static Asset References
@@ -1214,12 +1220,12 @@ if viper.GetString("METRICS_USERNAME") != "" {
### Environment Variables Summary
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `PORT` | HTTP listen port | 8080 |
-| `DEBUG` | Enable debug logging | false |
-| `DBURL` | Database connection URL | "" |
-| `SENTRY_DSN` | Sentry DSN for error reporting | "" |
-| `MAINTENANCE_MODE` | Enable maintenance mode | false |
-| `METRICS_USERNAME` | Basic auth username for /metrics | "" |
-| `METRICS_PASSWORD` | Basic auth password for /metrics | "" |
+| Variable | Description | Default |
+| ------------------ | -------------------------------- | ------- |
+| `PORT` | HTTP listen port | 8080 |
+| `DEBUG` | Enable debug logging | false |
+| `DBURL` | Database connection URL | "" |
+| `SENTRY_DSN` | Sentry DSN for error reporting | "" |
+| `MAINTENANCE_MODE` | Enable maintenance mode | false |
+| `METRICS_USERNAME` | Basic auth username for /metrics | "" |
+| `METRICS_PASSWORD` | Basic auth password for /metrics | "" |
diff --git a/README.md b/README.md
index 09ea48f..a7488e3 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,14 @@
# µPaaS by [@sneak](https://sneak.berlin)
-A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab.
+A simple self-hosted PaaS that auto-deploys Docker containers from Git
+repositories via webhooks from Gitea, GitHub, or GitLab.
## Features
- Single admin user with argon2id password hashing
- Per-app SSH keypairs for read-only deploy keys
-- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab
+- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and
+ GitLab
- Branch filtering - only deploy on configured branch changes
- Environment variables, labels, and volume mounts per app
- CPU and memory resource limits per app
@@ -95,9 +97,12 @@ chi Router ──► Middleware Stack ──► Handler
### Key Patterns
-- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing one-time initialization
-- **Active Record models**: Models encapsulate database operations (`Save()`, `Delete()`, `Reload()`)
-- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()`
+- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing
+ one-time initialization
+- **Active Record models**: Models encapsulate database operations (`Save()`,
+ `Delete()`, `Reload()`)
+- **Async deployments**: Webhook triggers deploy via goroutine with
+ `context.WithoutCancel()`
- **Embedded assets**: Templates and static files embedded via `//go:embed`
## Entrypoints
@@ -105,12 +110,12 @@ chi Router ──► Middleware Stack ──► Handler
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
-development workflow, and the Makefile targets are thin shims that call
-them. We provide:
+development workflow, and the Makefile targets are thin shims that call them. We
+provide:
- `script/bootstrap` — install all dependencies (idempotent)
-- `script/setup` — make a fresh clone ready for development
- (bootstrap, then install-precommit)
+- `script/setup` — make a fresh clone ready for development (bootstrap, then
+ install-precommit)
- `script/projectname` — output the project name ("upaas")
- `script/test` — run the test suite
- `script/lint` — run golangci-lint
@@ -118,12 +123,12 @@ them. We provide:
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`
-- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
- runs the checks, so a green build implies a green repo)
+- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the
+ checks, so a green build implies a green repo)
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
`script/check`)
-- `script/install-precommit` — install the git pre-commit hook that
- runs `script/precommit`
+- `script/install-precommit` — install the git pre-commit hook that runs
+ `script/precommit`
## Development
@@ -156,11 +161,11 @@ Before every commit:
1. **Format**: Run `make fmt` to format all code
2. **Lint**: Run `make lint` and fix all errors/warnings
- - Do not disable linters or add nolint comments without good reason
- - Fix the code, don't hide the problem
+ - Do not disable linters or add nolint comments without good reason
+ - Fix the code, don't hide the problem
3. **Test**: Run `make test` and ensure all tests pass
- - Fix failing tests by fixing the code, not by modifying tests to pass
- - Add tests for new functionality
+ - Fix failing tests by fixing the code, not by modifying tests to pass
+ - Add tests for new functionality
4. **Verify**: Run `make check` to confirm everything passes
```bash
@@ -174,6 +179,7 @@ git commit -m "Your message"
```
The Docker build runs `make check` and will fail if:
+
- Code is not formatted
- Linting errors exist
- Tests fail
@@ -185,17 +191,17 @@ This ensures the main branch always contains clean, tested, working code.
Environment variables:
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `PORT` | HTTP listen port | 8080 |
-| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) |
-| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | *(none — must be set to an absolute path)* |
-| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
-| `UPAAS_PLAINTEXT_HTTP` | Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use `http://`. Leave unset behind a TLS-terminating reverse proxy. | false |
-| `DEBUG` | Enable debug logging | false |
-| `SENTRY_DSN` | Sentry error reporting DSN | "" |
-| `METRICS_USERNAME` | Basic auth for /metrics | "" |
-| `METRICS_PASSWORD` | Basic auth for /metrics | "" |
+| Variable | Description | Default |
+| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
+| `PORT` | HTTP listen port | 8080 |
+| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) |
+| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | _(none — must be set to an absolute path)_ |
+| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
+| `UPAAS_PLAINTEXT_HTTP` | Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use `http://`. Leave unset behind a TLS-terminating reverse proxy. | false |
+| `DEBUG` | Enable debug logging | false |
+| `SENTRY_DSN` | Sentry error reporting DSN | "" |
+| `METRICS_USERNAME` | Basic auth for /metrics | "" |
+| `METRICS_PASSWORD` | Basic auth for /metrics | "" |
## Running with Docker
@@ -217,35 +223,38 @@ TLS-terminating reverse proxy, drop that line.
```yaml
services:
- upaas:
- build: .
- restart: unless-stopped
- ports:
- - "8080:8080"
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ${HOST_DATA_DIR}:/var/lib/upaas
- environment:
- - UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
- # Set when serving plain HTTP (no TLS-terminating proxy); drop behind one
- - UPAAS_PLAINTEXT_HTTP=true
- # Optional: uncomment to enable debug logging
- # - DEBUG=true
- # Optional: Sentry error reporting
- # - SENTRY_DSN=https://...
- # Optional: Prometheus metrics auth
- # - METRICS_USERNAME=prometheus
- # - METRICS_PASSWORD=secret
+ upaas:
+ build: .
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ${HOST_DATA_DIR}:/var/lib/upaas
+ environment:
+ - UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
+ # Set when serving plain HTTP (no TLS-terminating proxy); drop behind one
+ - UPAAS_PLAINTEXT_HTTP=true
+ # Optional: uncomment to enable debug logging
+ # - DEBUG=true
+ # Optional: Sentry error reporting
+ # - SENTRY_DSN=https://...
+ # Optional: Prometheus metrics auth
+ # - METRICS_USERNAME=prometheus
+ # - METRICS_PASSWORD=secret
```
-**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the host before running
-`docker compose up`. This value is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR`
-so that Docker bind mounts during builds resolve correctly. Relative paths (e.g. `./data`) will break
-container builds because the Docker daemon resolves paths relative to the host, not the container.
+**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the
+host before running `docker compose up`. This value is bind-mounted into the
+container and passed as `UPAAS_HOST_DATA_DIR` so that Docker bind mounts during
+builds resolve correctly. Relative paths (e.g. `./data`) will break container
+builds because the Docker daemon resolves paths relative to the host, not the
+container.
Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d`
-Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.
+Session secrets are automatically generated on first startup and persisted to
+`$UPAAS_DATA_DIR/session.key`.
## License
diff --git a/TODO.md b/TODO.md
index ae65dff..5f47fa4 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,70 +1,79 @@
# Workflow
-* branch (from `main`)
-* do the work in Next Step
-* move Next Step to the top of Completed Steps
-* move the top item of Future Steps into Next Step
-* commit (`TODO.md` changes in the same commit as the work)
-* merge to `main` if the branch is not protected, otherwise open a PR
-* push
+- branch (from `main`)
+- do the work in Next Step
+- move Next Step to the top of Completed Steps
+- move the top item of Future Steps into Next Step
+- commit (`TODO.md` changes in the same commit as the work)
+- merge to `main` if the branch is not protected, otherwise open a PR
+- push
# Status
-1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check`
-is green as of the golangci-lint v2.12.2 update.
+1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check` is green
+as of the golangci-lint v2.12.2 update.
# Next Step
-Confirm `.gitea/workflows/check.yml` gates merges on `make check` so
-main cannot regress.
+Confirm `.gitea/workflows/check.yml` gates merges on `make check` so main cannot
+regress.
# Completed Steps
+- 2026-09-23: Fixed the flaky `t.TempDir` cleanup race in `internal/handlers`
+ (the one fixed in `internal/service/webhook` by #198):
+ `TestHandleWebhookProcessesValidWebhook` now waits with the webhook service's
+ `WaitForDeployments` instead of sleeping (#211).
+- 2026-09-22: Vendored the canonical prettier/format toolchain from the
+ `sneak/prompts` scaffold: added `.prettierrc` (tabWidth 4, proseWrap always),
+ pinned `package.json` + `yarn.lock` (prettier 3.8.1), taught
+ `script/bootstrap` to install a pinned node/yarn via a hash-verified nvm
+ archive, and switched `script/fmt` to the pinned prettier reading
+ `.prettierrc` (no inline flags) over `static/js/*.js` and `**/*.md`. Reflowed
+ all markdown to house style; `alpine.min.js` stays byte-identical (#203).
- 2026-09-22: Fixed the flaky `t.TempDir` cleanup race in
- `internal/service/webhook` by tracking the async deployment goroutine
- in a `sync.WaitGroup` and exposing `WaitForDeployments`; tests now
- synchronize on completion instead of sleeping (#198).
-- 2026-09-22: Linting now runs only in Docker. Added `Dockerfile.lint`
- (pinned golangci-lint v2.12.2, cache-busted via a `GATE_RUN` build arg
- so the linter always executes), reduced `script/lint` to building it,
- dropped the golangci-lint install from `script/bootstrap`, and switched
- the `Dockerfile` lint stage to invoke `golangci-lint` directly instead
- of `make lint` to avoid docker-in-docker (#188).
-- 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites
- the vendored `static/js/alpine.min.js` bundle (#185).
-- 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy
- log download handler by verifying the resolved path stays within the
- deploy log directory before serving, returning 404 on escape (#177).
+ `internal/service/webhook` by tracking the async deployment goroutine in a
+ `sync.WaitGroup` and exposing `WaitForDeployments`; tests now synchronize on
+ completion instead of sleeping (#198).
+- 2026-09-22: Linting now runs only in Docker. Added `Dockerfile.lint` (pinned
+ golangci-lint v2.12.2, cache-busted via a `GATE_RUN` build arg so the linter
+ always executes), reduced `script/lint` to building it, dropped the
+ golangci-lint install from `script/bootstrap`, and switched the `Dockerfile`
+ lint stage to invoke `golangci-lint` directly instead of `make lint` to avoid
+ docker-in-docker (#188).
+- 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites the
+ vendored `static/js/alpine.min.js` bundle (#185).
+- 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy log
+ download handler by verifying the resolved path stays within the deploy log
+ directory before serving, returning 404 on escape (#177).
- 2026-09-22: `script/bootstrap` now installs a pinned `goimports`
- (`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt`
- succeeds on a fresh machine after `make bootstrap` (#184).
-- 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin
- check over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git
- image when absent (#190), the env-var editor CSRF token lookup (#191),
- and the port-mapping delete form's CSRF field (#192).
-- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical
- `.golangci.yml`, `Dockerfile` lint stage pin, `script/bootstrap`
- release-archive pins) and fixed all resulting lint findings (noctx,
- gosec, goconst, lll, dupl, nolintlint); `make check` green.
-- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
- Makefile shims, README Entrypoints section
+ (`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt` succeeds
+ on a fresh machine after `make bootstrap` (#184).
+- 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin check
+ over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git image when
+ absent (#190), the env-var editor CSRF token lookup (#191), and the
+ port-mapping delete form's CSRF field (#192).
+- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical `.golangci.yml`,
+ `Dockerfile` lint stage pin, `script/bootstrap` release-archive pins) and
+ fixed all resulting lint findings (noctx, gosec, goconst, lll, dupl,
+ nolintlint); `make check` green.
+- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
+ shims, README Entrypoints section
- 2026-03-11: Monolithic env var editing with bulk save (#158).
-- 2026-03-10: Webhook event history UI page (#164); added missing
- Makefile docker and hooks targets plus test timeout (#159);
- notification settings passed from create form (#160).
+- 2026-03-10: Webhook event history UI page (#164); added missing Makefile
+ docker and hooks targets plus test timeout (#159); notification settings
+ passed from create form (#160).
- 2026-03-03: REPO_POLICIES compliance file set added (#155).
-- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143);
- Dockerfile split into lint and build stages with forced lint
- execution (#152, #154).
+- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143); Dockerfile
+ split into lint and build stages with forced lint execution (#152, #154).
- 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146).
-- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error,
- deployment log size cap, error path rendering, docker-compose bind
- mount, domain type refactor.
+- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error, deployment
+ log size cap, error path rendering, docker-compose bind mount, domain type
+ refactor.
- CI simplified to docker build only (#130).
-- 2025-12-29 onward: core PaaS built out: deploys with real-time build
- log streaming, container start/stop/restart and logs, TCP/UDP port
- mapping, Alpine.js UI, Slack notifications, ULID app IDs, session
- handling.
+- 2025-12-29 onward: core PaaS built out: deploys with real-time build log
+ streaming, container start/stop/restart and logs, TCP/UDP port mapping,
+ Alpine.js UI, Slack notifications, ULID app IDs, session handling.
# Future Steps
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index 68e116e..6aa2037 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"testing"
- "time"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
@@ -43,6 +42,7 @@ type testContext struct {
authSvc *auth.Service
appSvc *app.Service
deploySvc *deploy.Service
+ webhookSvc *webhook.Service
middleware *middleware.Middleware
}
@@ -188,6 +188,7 @@ func setupTestHandlers(t *testing.T) *testContext {
authSvc: authSvc,
appSvc: appSvc,
deploySvc: deploySvc,
+ webhookSvc: webhookSvc,
middleware: mw,
}
}
@@ -1213,8 +1214,7 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code)
- // Allow async deployment goroutine to complete before test cleanup.
- // The deployment will fail quickly (docker not connected) but we need
- // to wait for it to finish to avoid temp directory cleanup race.
- time.Sleep(100 * time.Millisecond)
+ // Wait for the async deployment goroutine to finish so its writes
+ // under the temp dir complete before test cleanup.
+ testCtx.webhookSvc.WaitForDeployments()
}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..dc05cde
--- /dev/null
+++ b/package.json
@@ -0,0 +1,5 @@
+{
+ "devDependencies": {
+ "prettier": "3.8.1"
+ }
+}
diff --git a/script/bootstrap b/script/bootstrap
index 095afa8..58f9160 100755
--- a/script/bootstrap
+++ b/script/bootstrap
@@ -5,8 +5,12 @@
# or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). goimports is installed with `go install` at a pinned
# version (integrity via the Go module checksum database) into
-# /usr/local/bin so it is on PATH. The linter is not installed here: it
-# runs only in Docker via script/lint, so docker is its sole prerequisite.
+# /usr/local/bin so it is on PATH. Node is used directly if installed;
+# otherwise it is installed at a pinned version via nvm (installing nvm
+# itself first, from a hash-verified release archive, never curl | sh),
+# then the pinned prettier from yarn.lock. The linter is not installed
+# here: it runs only in Docker via script/lint, so docker is its sole
+# prerequisite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -15,6 +19,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# golang.org/x/tools goimports, 2026-08-13. v0.49.0 requires Go 1.25 (matches
# go.mod); v0.50.0 needs Go 1.26. Integrity via the Go module checksum database.
GOIMPORTS_VERSION="v0.49.0"
+# Node/yarn toolchain, 2026-07-06.
+NODE_VERSION="22.17.0"
+NVM_VERSION="0.40.3"
+# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
+NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
+YARN_VERSION="1.22.22"
PKGMGR=""
SUDO=""
@@ -56,6 +66,21 @@ missing() {
! command -v "$1" >/dev/null 2>&1
}
+# verify_sha256
+verify_sha256() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ actual="$(sha256sum "$1" | cut -d' ' -f1)"
+ else
+ actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
+ fi
+ if [ "$actual" != "$2" ]; then
+ echo "bootstrap: sha256 mismatch for $1" >&2
+ echo " expected: $2" >&2
+ echo " actual: $actual" >&2
+ exit 1
+ fi
+}
+
# goimports is not packaged uniformly across nix/apt/brew/apk, so install it
# with `go install` at a pinned version and place the binary in /usr/local/bin
# so it is on PATH regardless of shell config. Requires go, which main
@@ -69,6 +94,54 @@ ensure_goimports() {
rm -rf "$tmp"
}
+# nvm is a bash script; run a command in a bash with nvm loaded
+nvm_sh() {
+ bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
+}
+
+ensure_nvm() {
+ [ -s "$HOME/.nvm/nvm.sh" ] && return 0
+ # nvm prerequisites; nvm itself requires bash
+ if missing bash; then pkg_install bash bash bash bash; fi
+ if missing curl; then pkg_install curl curl curl curl; fi
+ if missing git; then pkg_install git git git git; fi
+ tmp="$(mktemp -d)"
+ curl -fsSL -o "$tmp/nvm.tar.gz" \
+ "https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
+ verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
+ mkdir -p "$HOME/.nvm"
+ tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
+ rm -rf "$tmp"
+}
+
+ensure_node() {
+ if ! missing node; then return 0; fi
+ ensure_nvm
+ nvm_sh "nvm install $NODE_VERSION"
+}
+
+ensure_yarn() {
+ if ! missing yarn; then return 0; fi
+ if ! missing corepack; then
+ corepack enable
+ corepack prepare "yarn@$YARN_VERSION" --activate
+ elif [ -s "$HOME/.nvm/nvm.sh" ]; then
+ nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
+ corepack prepare yarn@$YARN_VERSION --activate"
+ else
+ npm install -g "yarn@$YARN_VERSION"
+ fi
+}
+
+install_js_deps() {
+ if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
+ nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
+ yarn install --frozen-lockfile"
+ else
+ yarn install --frozen-lockfile
+ fi
+}
+
main() {
cd "$ROOT"
@@ -80,6 +153,11 @@ main() {
if missing go; then pkg_install go golang go go; fi
ensure_goimports
+ # Node toolchain and pinned prettier
+ ensure_node
+ ensure_yarn
+ install_js_deps
+
# The linter runs only in Docker (script/lint). Warn, don't fail: the
# rest of the repo works without it.
if missing docker; then
diff --git a/script/fmt b/script/fmt
index 2c9c55c..d7ab4f3 100755
--- a/script/fmt
+++ b/script/fmt
@@ -4,11 +4,34 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+# Must match the pin in script/bootstrap.
+NODE_VERSION="22.17.0"
+
+# script/bootstrap installs node and yarn under nvm and leaves neither
+# on the PATH of the shell that called it, so resolve the pinned
+# toolchain here the way bootstrap's own install step does. nvm is a
+# bash script, hence the subshell.
+run_yarn() {
+ if command -v yarn >/dev/null 2>&1; then
+ yarn "$@"
+ return
+ fi
+ if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
+ echo "fmt: no yarn; run script/bootstrap first" >&2
+ exit 1
+ fi
+ bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
+ shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
+}
+
main() {
cd "$ROOT"
gofmt -s -w .
goimports -w .
- npx prettier --write --tab-width 4 static/js/*.js
+ # Pinned prettier reads settings from .prettierrc (tabWidth 4,
+ # proseWrap always); .prettierignore keeps alpine.min.js untouched.
+ # Globs are quoted so prettier expands them, not the shell.
+ run_yarn run prettier --write 'static/js/*.js' '**/*.md'
}
main "$@"
diff --git a/yarn.lock b/yarn.lock
new file mode 100644
index 0000000..d846639
--- /dev/null
+++ b/yarn.lock
@@ -0,0 +1,8 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+prettier@3.8.1:
+ version "3.8.1"
+ resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
+ integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==