9 Commits
Author SHA1 Message Date
clawbot 19619b1cd2 Wait for webhook deployments in handlers tests instead of sleeping (closes #211)
Check / check (pull_request) Successful in 3m24s
`TestHandleWebhookProcessesValidWebhook` waited for the background deployment with a 100 ms sleep, so its temp directory could be removed while the deployment was still writing its log there, and the test failed now and then. It now waits with the webhook service's `WaitForDeployments`, the fix already applied to the webhook service tests for #198. The test context keeps the webhook service it already built. No other handlers test can start a deployment. No production code change.

Model: opus-5-5
2026-09-23 03:04:31 +02:00
clawbot 567f982c3b Re-vendor canonical .golangci.yml from prompts (closes #208)
Check / check (pull_request) Successful in 3m35s
Replaces the local `.golangci.yml` with the canonical file from `sneak/prompts`, fetched unchanged. This turns off the deprecated `gomodguard` (removing its warning from every lint run), turns on `depguard`, and gives the already-running `gomodguard_v2` its module block list. The new config finds nothing in upaas, so no source changes. The `test-support` deny list stays canonical because upaas has no separate test-support packages.

Model: opus-5-5
2026-09-23 02:34:27 +02:00
clawbot 1d769834de Vendor pinned prettier/format toolchain from prompts scaffold (closes #203)
Check / check (pull_request) Successful in 3m15s
script/fmt ran unpinned npx prettier with an inline --tab-width and no config, so formatting was not reproducible. This vendors the canonical format toolchain from the prompts scaffold: .prettierrc (tabWidth 4, proseWrap always), package.json and yarn.lock pinning prettier 3.8.1, and a script/bootstrap that installs node/yarn from a hash-verified nvm archive. script/fmt now reads the config over static/js and markdown; gofmt/goimports unchanged. .prettierignore keeps the *.min.js rule protecting the vendored alpine.min.js. Existing markdown was reflowed to house style.

Disclosure: make check does not gate prettier; the linter's pre-existing gomodguard deprecation warning is unrelated and left as-is.

Model: opus-4-8 (implementation and review)
2026-09-22 18:43:58 +00:00
sneak 6026e3923d 1.1.0 milestone: security, lint, and bootstrap fixes (#199)
Check / check (push) Successful in 5s
Reviewed-on: #199
2026-09-22 20:39:11 +02:00
clawbot f2e4be5eed Fix flaky t.TempDir cleanup race in webhook tests (closes #198)
Check / check (pull_request) Successful in 3m33s
HandleWebhook starts a deployment in a detached goroutine that writes
under the app data directory, which is the tests t.TempDir; the tests
slept 100ms and returned, racing Go automatic TempDir cleanup and
intermittently failing with RemoveAll: directory not empty. The webhook
Service now tracks those goroutines in a sync.WaitGroup and exposes
WaitForDeployments; the tests wait on it instead of sleeping. Production
behavior is unchanged apart from making completion observable.

Model: opus-4-8
2026-09-22 12:28:23 +02:00
clawbot d946fa68f9 Run all linting in Docker via Dockerfile.lint (closes #188)
Check / check (pull_request) Successful in 1m29s
Per the owner ruling, linting now runs only inside Docker with the
pinned golangci-lint (v2.12.2). A root Dockerfile.lint runs the linter as
a build step; script/lint just builds it. A GATE_RUN build arg forces the
lint layer to execute every run so a cached build cannot report a false
clean. script/bootstrap no longer installs golangci-lint (the goimports
install stays). The main Dockerfile lint stage calls golangci-lint
directly (no docker-in-docker) and still gates the build. config verify is
omitted because it fetches its schema over an unpinned HTTPS call.

Model: opus-4-8
2026-09-22 12:11:19 +02:00
clawbot 727bd50935 Install pinned goimports in script/bootstrap (closes #184)
Check / check (pull_request) Successful in 1m49s
script/fmt runs goimports, but script/bootstrap did not install it, so
make fmt failed with goimports: not found on a fresh machine. bootstrap
now installs goimports v0.49.0 (pinned; compatible with the repo Go 1.25,
so no toolchain download) into /usr/local/bin, guarded to skip when it is
already present. Node/prettier pinning is left to a separate issue; the
check gate runs only gofmt, so main is unaffected.

Model: opus-4-8
2026-09-22 11:11:15 +02:00
clawbot f1dfd382a4 Reject path traversal in deploy log download handler (closes #177)
The deploy-log download handler passed a request-derived path to
http.ServeFile, which gosec flags as G703 (path traversal via taint).
The handler now opens the log through an os.Root confined to the deploy
log directory, so any escaping path is rejected at runtime (404) and the
file is streamed with http.ServeContent. A regression test plants a
sentinel outside the log dir and asserts the traversal is refused and its
contents never served; removing the guard makes that test fail. No
//nolint used.

Model: opus-4-8
2026-09-22 11:01:07 +02:00
clawbot 1d38585431 Add .prettierignore for vendored minified JS (closes #185)
script/fmt ran prettier over static/js/*.js, which rewrote the vendored
minified static/js/alpine.min.js. A root .prettierignore with *.min.js
excludes vendored bundles: make fmt on a clean tree now yields no changes
and alpine.min.js stays byte-identical, while first-party JS still formats.

Model: opus-4-8
2026-09-22 10:00:50 +02:00
19 changed files with 577 additions and 191 deletions
+66 -2
View File
@@ -10,14 +10,20 @@ run:
linters: linters:
default: all 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: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - 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: settings:
lll: lll:
line-length: 88 line-length: 88
@@ -28,6 +34,64 @@ linters:
max-complexity: 15 max-complexity: 15
dupl: dupl:
threshold: 100 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: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
+5
View File
@@ -0,0 +1,5 @@
node_modules/
yarn.lock
# Vendored, minified third-party bundles must never be reformatted.
*.min.js
+4
View File
@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
+37 -31
View File
@@ -1,6 +1,8 @@
# Go HTTP Server Conventions # 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 ## 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: These libraries are **mandatory** for all new projects:
| Purpose | Library | Import Path | | Purpose | Library | Import Path |
|---------|---------|-------------| | -------------------- | --------------- | ------------------------------------- |
| Dependency Injection | Uber fx | `go.uber.org/fx` | | Dependency Injection | Uber fx | `go.uber.org/fx` |
| HTTP Router | go-chi | `github.com/go-chi/chi` | | HTTP Router | go-chi | `github.com/go-chi/chi` |
| Logging | slog (stdlib) | `log/slog` | | Logging | slog (stdlib) | `log/slog` |
| Configuration | Viper | `github.com/spf13/viper` | | Configuration | Viper | `github.com/spf13/viper` |
| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` | | Environment Loading | godotenv | `github.com/joho/godotenv/autoload` |
| CORS | go-chi/cors | `github.com/go-chi/cors` | | CORS | go-chi/cors | `github.com/go-chi/cors` |
| Error Reporting | Sentry | `github.com/getsentry/sentry-go` | | Error Reporting | Sentry | `github.com/getsentry/sentry-go` |
| Metrics | Prometheus | `github.com/prometheus/client_golang` | | Metrics | Prometheus | `github.com/prometheus/client_golang` |
| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` | | Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` |
| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` | | Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` |
--- ---
@@ -85,7 +87,8 @@ project-root/
### Key Principles ### Key Principles
- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping. - **`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. - **One package per concern**: config, database, handlers, middleware, etc.
- **Flat handler files**: One file per handler or logical group of handlers. - **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) 2. `logger.New` - Logger (depends on Globals)
3. `config.New` - Configuration (depends on Globals, Logger) 3. `config.New` - Configuration (depends on Globals, Logger)
4. `database.New` - Database (depends on Logger, Config) 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) 6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck) 7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
8. `server.New` - Server (depends on all above) 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 ### 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 ```go
// internal/handlers/index.go // internal/handlers/index.go
@@ -510,7 +515,8 @@ func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interfac
### Handler Naming Convention ### Handler Naming Convention
- `HandleIndex()` - Main page - `HandleIndex()` - Main page
- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix - `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method
suffix
- `HandleNow()` - API endpoints - `HandleNow()` - API endpoints
- `HandleHealthCheck()` - System endpoints - `HandleHealthCheck()` - System endpoints
@@ -733,7 +739,8 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
1. **Environment variables** (highest priority via `AutomaticEnv()`) 1. **Environment variables** (highest priority via `AutomaticEnv()`)
2. **`.env` file** (loaded via `godotenv/autoload` import) 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) 4. **Defaults** (lowest priority)
### Environment Loading ### Environment Loading
@@ -1005,6 +1012,7 @@ var Static embed.FS
``` ```
Directory structure: Directory structure:
``` ```
static/ static/
├── static.go ├── static.go
@@ -1045,15 +1053,13 @@ Templates use Go's template composition:
```html ```html
<!-- index.html --> <!-- index.html -->
{{ template "htmlheader.html" . }} {{ template "htmlheader.html" . }} {{ template "navbar.html" . }}
{{ template "navbar.html" . }}
<main> <main>
<!-- Page content --> <!-- Page content -->
</main> </main>
{{ template "pagefooter.html" . }} {{ template "pagefooter.html" . }} {{ template "htmlfooter.html" . }}
{{ template "htmlfooter.html" . }}
``` ```
### Static Asset References ### Static Asset References
@@ -1214,12 +1220,12 @@ if viper.GetString("METRICS_USERNAME") != "" {
### Environment Variables Summary ### Environment Variables Summary
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| | ------------------ | -------------------------------- | ------- |
| `PORT` | HTTP listen port | 8080 | | `PORT` | HTTP listen port | 8080 |
| `DEBUG` | Enable debug logging | false | | `DEBUG` | Enable debug logging | false |
| `DBURL` | Database connection URL | "" | | `DBURL` | Database connection URL | "" |
| `SENTRY_DSN` | Sentry DSN for error reporting | "" | | `SENTRY_DSN` | Sentry DSN for error reporting | "" |
| `MAINTENANCE_MODE` | Enable maintenance mode | false | | `MAINTENANCE_MODE` | Enable maintenance mode | false |
| `METRICS_USERNAME` | Basic auth username for /metrics | "" | | `METRICS_USERNAME` | Basic auth username for /metrics | "" |
| `METRICS_PASSWORD` | Basic auth password for /metrics | "" | | `METRICS_PASSWORD` | Basic auth password for /metrics | "" |
+5 -1
View File
@@ -8,8 +8,12 @@ RUN go mod download
COPY . . COPY . .
# golangci-lint is invoked directly here, not via `make lint`: script/lint
# now runs the linter by building Dockerfile.lint, and shelling out to
# `docker build` from inside this image build would be docker-in-docker.
# This image is golangci/golangci-lint, so the pinned linter is on PATH.
RUN make fmt-check RUN make fmt-check
RUN make lint RUN golangci-lint run --config .golangci.yml ./...
# Build stage — tests and compilation # Build stage — tests and compilation
# golang:1.25-alpine # golang:1.25-alpine
+23
View File
@@ -0,0 +1,23 @@
# Lint image — runs golangci-lint inside a container so every lint uses
# the pinned linter, never a host binary. Linting is a build step, so a
# successful build is a clean lint. Built by script/lint.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Caching is waived for linting: on an unchanged tree a cached build runs
# no linter and still exits 0 in under a second. script/lint passes a
# fresh GATE_RUN every time, and referencing it here forces this step to
# re-run, so the linter always executes.
#
# `golangci-lint config verify` is deliberately NOT run: it fetches its
# JSON schema over an unpinned live HTTPS call, which REPO_POLICIES.md
# forbids (all external references must be pinned by hash).
ARG GATE_RUN
RUN echo "lint run: ${GATE_RUN}"; golangci-lint run --config .golangci.yml ./...
+61 -52
View File
@@ -1,12 +1,14 @@
# µPaaS by [@sneak](https://sneak.berlin) # µ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 ## Features
- Single admin user with argon2id password hashing - Single admin user with argon2id password hashing
- Per-app SSH keypairs for read-only deploy keys - 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 - Branch filtering - only deploy on configured branch changes
- Environment variables, labels, and volume mounts per app - Environment variables, labels, and volume mounts per app
- CPU and memory resource limits per app - CPU and memory resource limits per app
@@ -95,9 +97,12 @@ chi Router ──► Middleware Stack ──► Handler
### Key Patterns ### Key Patterns
- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing one-time initialization - **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing
- **Active Record models**: Models encapsulate database operations (`Save()`, `Delete()`, `Reload()`) one-time initialization
- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()` - **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` - **Embedded assets**: Templates and static files embedded via `//go:embed`
## Entrypoints ## Entrypoints
@@ -105,12 +110,12 @@ chi Router ──► Middleware Stack ──► Handler
This repository adheres to the This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call development workflow, and the Makefile targets are thin shims that call them. We
them. We provide: provide:
- `script/bootstrap` — install all dependencies (idempotent) - `script/bootstrap` — install all dependencies (idempotent)
- `script/setup` — make a fresh clone ready for development - `script/setup` — make a fresh clone ready for development (bootstrap, then
(bootstrap, then install-precommit) install-precommit)
- `script/projectname` — output the project name ("upaas") - `script/projectname` — output the project name ("upaas")
- `script/test` — run the test suite - `script/test` — run the test suite
- `script/lint` — run golangci-lint - `script/lint` — run golangci-lint
@@ -118,12 +123,12 @@ them. We provide:
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname` - `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile - `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the
runs the checks, so a green build implies a green repo) checks, so a green build implies a green repo)
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then - `script/precommit` — pre-commit checks (`go mod tidy` guard, then
`script/check`) `script/check`)
- `script/install-precommit` — install the git pre-commit hook that - `script/install-precommit` — install the git pre-commit hook that runs
runs `script/precommit` `script/precommit`
## Development ## Development
@@ -156,11 +161,11 @@ Before every commit:
1. **Format**: Run `make fmt` to format all code 1. **Format**: Run `make fmt` to format all code
2. **Lint**: Run `make lint` and fix all errors/warnings 2. **Lint**: Run `make lint` and fix all errors/warnings
- Do not disable linters or add nolint comments without good reason - Do not disable linters or add nolint comments without good reason
- Fix the code, don't hide the problem - Fix the code, don't hide the problem
3. **Test**: Run `make test` and ensure all tests pass 3. **Test**: Run `make test` and ensure all tests pass
- Fix failing tests by fixing the code, not by modifying tests to pass - Fix failing tests by fixing the code, not by modifying tests to pass
- Add tests for new functionality - Add tests for new functionality
4. **Verify**: Run `make check` to confirm everything passes 4. **Verify**: Run `make check` to confirm everything passes
```bash ```bash
@@ -174,6 +179,7 @@ git commit -m "Your message"
``` ```
The Docker build runs `make check` and will fail if: The Docker build runs `make check` and will fail if:
- Code is not formatted - Code is not formatted
- Linting errors exist - Linting errors exist
- Tests fail - Tests fail
@@ -185,17 +191,17 @@ This ensures the main branch always contains clean, tested, working code.
Environment variables: Environment variables:
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `PORT` | HTTP listen port | 8080 | | `PORT` | HTTP listen port | 8080 |
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) | | `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_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_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 | | `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 | | `DEBUG` | Enable debug logging | false |
| `SENTRY_DSN` | Sentry error reporting DSN | "" | | `SENTRY_DSN` | Sentry error reporting DSN | "" |
| `METRICS_USERNAME` | Basic auth for /metrics | "" | | `METRICS_USERNAME` | Basic auth for /metrics | "" |
| `METRICS_PASSWORD` | Basic auth for /metrics | "" | | `METRICS_PASSWORD` | Basic auth for /metrics | "" |
## Running with Docker ## Running with Docker
@@ -217,35 +223,38 @@ TLS-terminating reverse proxy, drop that line.
```yaml ```yaml
services: services:
upaas: upaas:
build: . build: .
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ${HOST_DATA_DIR}:/var/lib/upaas - ${HOST_DATA_DIR}:/var/lib/upaas
environment: environment:
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR} - UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
# Set when serving plain HTTP (no TLS-terminating proxy); drop behind one # Set when serving plain HTTP (no TLS-terminating proxy); drop behind one
- UPAAS_PLAINTEXT_HTTP=true - UPAAS_PLAINTEXT_HTTP=true
# Optional: uncomment to enable debug logging # Optional: uncomment to enable debug logging
# - DEBUG=true # - DEBUG=true
# Optional: Sentry error reporting # Optional: Sentry error reporting
# - SENTRY_DSN=https://... # - SENTRY_DSN=https://...
# Optional: Prometheus metrics auth # Optional: Prometheus metrics auth
# - METRICS_USERNAME=prometheus # - METRICS_USERNAME=prometheus
# - METRICS_PASSWORD=secret # - METRICS_PASSWORD=secret
``` ```
**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the host before running **Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the
`docker compose up`. This value is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR` host before running `docker compose up`. This value is bind-mounted into the
so that Docker bind mounts during builds resolve correctly. Relative paths (e.g. `./data`) will break container and passed as `UPAAS_HOST_DATA_DIR` so that Docker bind mounts during
container builds because the Docker daemon resolves paths relative to the host, not the container. 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` 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 ## License
+60 -36
View File
@@ -1,55 +1,79 @@
# Workflow # Workflow
* branch (from `main`) - branch (from `main`)
* do the work in Next Step - do the work in Next Step
* move Next Step to the top of Completed Steps - move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step - move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work) - commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR - merge to `main` if the branch is not protected, otherwise open a PR
* push - push
# Status # Status
1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check` 1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check` is green
is green as of the golangci-lint v2.12.2 update. as of the golangci-lint v2.12.2 update.
# Next Step # Next Step
Confirm `.gitea/workflows/check.yml` gates merges on `make check` so Confirm `.gitea/workflows/check.yml` gates merges on `make check` so main cannot
main cannot regress. regress.
# Completed Steps # 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).
- 2026-09-22: `script/bootstrap` now installs a pinned `goimports` - 2026-09-22: `script/bootstrap` now installs a pinned `goimports`
(`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt` (`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt` succeeds
succeeds on a fresh machine after `make bootstrap` (#184). on a fresh machine after `make bootstrap` (#184).
- 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin - 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin check
check over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git image when
image when absent (#190), the env-var editor CSRF token lookup (#191), absent (#190), the env-var editor CSRF token lookup (#191), and the
and the port-mapping delete form's CSRF field (#192). port-mapping delete form's CSRF field (#192).
- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical - 2026-08-07: Updated golangci-lint to v2.12.2 (canonical `.golangci.yml`,
`.golangci.yml`, `Dockerfile` lint stage pin, `script/bootstrap` `Dockerfile` lint stage pin, `script/bootstrap` release-archive pins) and
release-archive pins) and fixed all resulting lint findings (noctx, fixed all resulting lint findings (noctx, gosec, goconst, lll, dupl,
gosec, goconst, lll, dupl, nolintlint); `make check` green. nolintlint); `make check` green.
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
Makefile shims, README Entrypoints section shims, README Entrypoints section
- 2026-03-11: Monolithic env var editing with bulk save (#158). - 2026-03-11: Monolithic env var editing with bulk save (#158).
- 2026-03-10: Webhook event history UI page (#164); added missing - 2026-03-10: Webhook event history UI page (#164); added missing Makefile
Makefile docker and hooks targets plus test timeout (#159); docker and hooks targets plus test timeout (#159); notification settings
notification settings passed from create form (#160). passed from create form (#160).
- 2026-03-03: REPO_POLICIES compliance file set added (#155). - 2026-03-03: REPO_POLICIES compliance file set added (#155).
- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143); - 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143); Dockerfile
Dockerfile split into lint and build stages with forced lint split into lint and build stages with forced lint execution (#152, #154).
execution (#152, #154).
- 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146). - 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146).
- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error, - 1.0 audit bug fixes (#120-#125): deferred rollback on commit error, deployment
deployment log size cap, error path rendering, docker-compose bind log size cap, error path rendering, docker-compose bind mount, domain type
mount, domain type refactor. refactor.
- CI simplified to docker build only (#130). - CI simplified to docker build only (#130).
- 2025-12-29 onward: core PaaS built out: deploys with real-time build - 2025-12-29 onward: core PaaS built out: deploys with real-time build log
log streaming, container start/stop/restart and logs, TCP/UDP port streaming, container start/stop/restart and logs, TCP/UDP port mapping,
mapping, Alpine.js UI, Slack notifications, ULID app IDs, session Alpine.js UI, Slack notifications, ULID app IDs, session handling.
handling.
# Future Steps # Future Steps
+29 -8
View File
@@ -611,7 +611,13 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return return
} }
// Get the log file path from deploy service // The log path is derived from request data (the app is looked
// up by a URL parameter), so open it through an os.Root confined
// to the deploy log directory. Root.Open rejects any path that
// escapes the root, so a traversal attempt fails rather than
// serving an arbitrary file.
logDir := h.deploy.GetLogDir()
logPath := h.deploy.GetLogFilePath(application, deployment) logPath := h.deploy.GetLogFilePath(application, deployment)
if logPath == "" { if logPath == "" {
http.NotFound(writer, request) http.NotFound(writer, request)
@@ -619,28 +625,43 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return return
} }
// Check if file exists — logPath is constructed internally, not from user input relPath, relErr := filepath.Rel(logDir, logPath)
_, err := os.Stat(logPath) // #nosec G703 -- internal path, not user input if relErr != nil {
if os.IsNotExist(err) {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
} }
if err != nil { root, rootErr := os.OpenRoot(logDir)
h.log.Error("failed to stat log file", "error", err, "path", logPath) if rootErr != nil {
http.NotFound(writer, request)
return
}
defer func() { _ = root.Close() }()
file, openErr := root.Open(relPath)
if openErr != nil {
http.NotFound(writer, request)
return
}
defer func() { _ = file.Close() }()
info, statErr := file.Stat()
if statErr != nil {
h.log.Error("failed to stat log file", "error", statErr, "path", logPath)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError) http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return return
} }
// Extract filename for Content-Disposition header
filename := filepath.Base(logPath) filename := filepath.Base(logPath)
writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
http.ServeFile(writer, request, logPath) // #nosec G703 -- internal path http.ServeContent(writer, request, filename, info.ModTime(), file)
} }
} }
+7 -5
View File
@@ -8,7 +8,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -42,6 +41,8 @@ type testContext struct {
database *database.Database database *database.Database
authSvc *auth.Service authSvc *auth.Service
appSvc *app.Service appSvc *app.Service
deploySvc *deploy.Service
webhookSvc *webhook.Service
middleware *middleware.Middleware middleware *middleware.Middleware
} }
@@ -186,6 +187,8 @@ func setupTestHandlers(t *testing.T) *testContext {
database: dbInstance, database: dbInstance,
authSvc: authSvc, authSvc: authSvc,
appSvc: appSvc, appSvc: appSvc,
deploySvc: deploySvc,
webhookSvc: webhookSvc,
middleware: mw, middleware: mw,
} }
} }
@@ -1211,8 +1214,7 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, http.StatusOK, recorder.Code)
// Allow async deployment goroutine to complete before test cleanup. // Wait for the async deployment goroutine to finish so its writes
// The deployment will fail quickly (docker not connected) but we need // under the temp dir complete before test cleanup.
// to wait for it to finish to avoid temp directory cleanup race. testCtx.webhookSvc.WaitForDeployments()
time.Sleep(100 * time.Millisecond)
} }
+121
View File
@@ -0,0 +1,121 @@
package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/models"
)
// doLogDownload issues a log-download request for the given app and
// deployment and returns the recorder.
func doLogDownload(
t *testing.T,
testCtx *testContext,
appID string,
deploymentID int64,
) *httptest.ResponseRecorder {
t.Helper()
idStr := strconv.FormatInt(deploymentID, 10)
request := httptest.NewRequestWithContext(
t.Context(),
http.MethodGet,
"/apps/"+appID+"/deployments/"+idStr+"/log",
nil,
)
request = addChiURLParams(request, map[string]string{
"id": appID,
"deploymentID": idStr,
})
recorder := httptest.NewRecorder()
testCtx.handlers.HandleDeploymentLogDownload().ServeHTTP(recorder, request)
return recorder
}
// TestHandleDeploymentLogDownloadServesLegitimateFile verifies a normal
// log file is served for download.
func TestHandleDeploymentLogDownloadServesLegitimateFile(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "log-download-app")
deployment := models.NewDeployment(testCtx.database)
deployment.AppID = createdApp.ID
deployment.Status = models.DeploymentStatusSuccess
require.NoError(t, deployment.Save(context.Background()))
// Write the log file where the handler will look for it.
logPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0o750))
require.NoError(t, os.WriteFile(logPath, []byte("deploy log contents"), 0o600))
recorder := doLogDownload(t, testCtx, createdApp.ID, deployment.ID)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "deploy log contents")
}
// TestHandleDeploymentLogDownloadRejectsPathTraversal verifies the
// os.Root containment guard. A traversal-shaped app name drives the
// resolved log path out of the deploy log directory onto a sentinel
// file that really exists. The handler must refuse to serve it (404)
// rather than leak its contents. Removing the guard makes this test
// fail, which the earlier version — pointed at a non-existent path that
// 404s either way — did not.
func TestHandleDeploymentLogDownloadRejectsPathTraversal(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "log-traversal-app")
createdApp.Name = "../.."
require.NoError(t, createdApp.Save(context.Background()))
// The log root must exist so os.OpenRoot succeeds and the rejection
// comes from the containment check, not a missing directory.
logDir := testCtx.deploySvc.GetLogDir()
require.NoError(t, os.MkdirAll(logDir, 0o750))
deployment := models.NewDeployment(testCtx.database)
deployment.AppID = createdApp.ID
deployment.Status = models.DeploymentStatusSuccess
require.NoError(t, deployment.Save(context.Background()))
// Where the handler resolves the log path to. The traversal name
// makes this land outside logDir; require that it truly escapes so
// the test cannot silently stop covering the guard.
escapedPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
relPath, relErr := filepath.Rel(logDir, escapedPath)
require.NoError(t, relErr)
require.True(t, strings.HasPrefix(relPath, ".."),
"resolved path must escape the log dir, got %q", relPath)
// Plant a sentinel where the traversal points; a missing guard would
// open and serve it.
require.NoError(t, os.MkdirAll(filepath.Dir(escapedPath), 0o750))
const sentinel = "SENTINEL-outside-log-dir-must-not-be-served"
require.NoError(t, os.WriteFile(escapedPath, []byte(sentinel), 0o600))
t.Cleanup(func() { _ = os.Remove(escapedPath) })
recorder := doLogDownload(t, testCtx, createdApp.ID, deployment.ID)
assert.Equal(t, http.StatusNotFound, recorder.Code)
assert.NotContains(t, recorder.Body.String(), sentinel,
"containment guard must not serve a file outside the log dir")
}
+6
View File
@@ -294,6 +294,12 @@ func (svc *Service) GetLogFilePath(
return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename) return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename)
} }
// GetLogDir returns the root directory under which all deployment log
// files live. Paths returned by GetLogFilePath are always inside it.
func (svc *Service) GetLogDir() string {
return filepath.Join(svc.config.DataDir, "logs")
}
// HasActiveDeploy returns true if there is an active deployment for the given app. // HasActiveDeploy returns true if there is an active deployment for the given app.
func (svc *Service) HasActiveDeploy(appID string) bool { func (svc *Service) HasActiveDeploy(appID string) bool {
_, ok := svc.activeDeploys.Load(appID) _, ok := svc.activeDeploys.Load(appID)
+15 -2
View File
@@ -6,6 +6,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"sync"
"go.uber.org/fx" "go.uber.org/fx"
@@ -31,6 +32,10 @@ type Service struct {
db *database.Database db *database.Database
deploy *deploy.Service deploy *deploy.Service
params *ServiceParams params *ServiceParams
// deployments tracks the deployment goroutines started by
// triggerDeployment so callers can wait for them to finish.
deployments sync.WaitGroup
} }
// New creates a new webhook Service. // New creates a new webhook Service.
@@ -108,6 +113,14 @@ func (svc *Service) HandleWebhook(
return nil return nil
} }
// WaitForDeployments blocks until every deployment goroutine started by
// HandleWebhook has finished, including all writes under the data
// directory. It exists so callers and tests can synchronize on async
// deployment completion instead of polling or sleeping.
func (svc *Service) WaitForDeployments() {
svc.deployments.Wait()
}
func (svc *Service) triggerDeployment( func (svc *Service) triggerDeployment(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
@@ -117,7 +130,7 @@ func (svc *Service) triggerDeployment(
eventID := event.ID eventID := event.ID
appName := app.Name appName := app.Name
go func() { svc.deployments.Go(func() {
// Use context.WithoutCancel to ensure deployment completes // Use context.WithoutCancel to ensure deployment completes
// even if the HTTP request context is cancelled. // even if the HTTP request context is cancelled.
deployCtx := context.WithoutCancel(ctx) deployCtx := context.WithoutCancel(ctx)
@@ -130,5 +143,5 @@ func (svc *Service) triggerDeployment(
// Mark event as processed // Mark event as processed
event.Processed = true event.Processed = true
_ = event.Save(deployCtx) _ = event.Save(deployCtx)
}() })
} }
+9 -7
View File
@@ -7,7 +7,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -828,8 +827,9 @@ func TestExtractBranch(testingT *testing.T) {
) )
require.NoError(t, err) require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup // Wait for the async deployment goroutine to finish so its
time.Sleep(100 * time.Millisecond) // writes under the temp dir complete before test cleanup.
svc.WaitForDeployments()
events, err := app.GetWebhookEvents(context.Background(), 10) events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err) require.NoError(t, err)
@@ -867,8 +867,9 @@ func TestHandleWebhookMatchingBranch(t *testing.T) {
) )
require.NoError(t, err) require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup // Wait for the async deployment goroutine to finish so its writes
time.Sleep(100 * time.Millisecond) // under the temp dir complete before test cleanup.
svc.WaitForDeployments()
events, err := app.GetWebhookEvents(context.Background(), 10) events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err) require.NoError(t, err)
@@ -962,8 +963,9 @@ func assertHandleWebhookDeploys(
err := svc.HandleWebhook(context.Background(), app, source, pushEventType, payload) err := svc.HandleWebhook(context.Background(), app, source, pushEventType, payload)
require.NoError(t, err) require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup // Wait for the async deployment goroutine to finish so its writes
time.Sleep(100 * time.Millisecond) // under the temp dir complete before test cleanup.
svc.WaitForDeployments()
events, err := app.GetWebhookEvents(context.Background(), 10) events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err) require.NoError(t, err)
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"prettier": "3.8.1"
}
}
+78 -44
View File
@@ -3,23 +3,28 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git, # or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt # make, or go). goimports is installed with `go install` at a pinned
# it is installed from a hash-verified GitHub release archive (never
# curl | sh). goimports is installed with `go install` at a pinned
# version (integrity via the Go module checksum database) into # version (integrity via the Go module checksum database) into
# /usr/local/bin so it is on PATH. # /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 set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-08-07. Never "latest"; exact versions only. # Pinned versions. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.12.2"
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
# golang.org/x/tools goimports, 2026-08-13. v0.49.0 requires Go 1.25 (matches # 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. # go.mod); v0.50.0 needs Go 1.26. Integrity via the Go module checksum database.
GOIMPORTS_VERSION="v0.49.0" 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="" PKGMGR=""
SUDO="" SUDO=""
@@ -76,41 +81,10 @@ verify_sha256() {
fi fi
} }
# apt has no golangci-lint package: install a pinned release archive
# from GitHub, verified by hardcoded sha256 (never curl | sh).
install_golangci_lint_release() {
case "$(uname -m)" in
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
*)
echo "bootstrap: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
if missing curl; then pkg_install curl curl curl curl; fi
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/$name.tar.gz" \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
verify_sha256 "$tmp/$name.tar.gz" "$sha"
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
rm -rf "$tmp"
}
ensure_golangci_lint() {
if ! missing golangci-lint; then return 0; fi
detect_pkgmgr
case "$PKGMGR" in
apt) install_golangci_lint_release ;;
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
esac
}
# goimports is not packaged uniformly across nix/apt/brew/apk, so install it # 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 # with `go install` at a pinned version and place the binary in /usr/local/bin
# so it is on PATH regardless of shell config, as the golangci-lint release # so it is on PATH regardless of shell config. Requires go, which main
# install does. Requires go, which main installs first. # installs first.
ensure_goimports() { ensure_goimports() {
if ! missing goimports; then return 0; fi if ! missing goimports; then return 0; fi
detect_pkgmgr detect_pkgmgr
@@ -120,6 +94,54 @@ ensure_goimports() {
rm -rf "$tmp" 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() { main() {
cd "$ROOT" cd "$ROOT"
@@ -127,11 +149,23 @@ main() {
if missing git; then pkg_install git git git git; fi if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
# Go toolchain and linter # Go toolchain
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
ensure_golangci_lint
ensure_goimports 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
echo "bootstrap: WARNING: docker not found; make lint and" >&2
echo "bootstrap: make check require it. Install docker to run" >&2
echo "bootstrap: the linter." >&2
fi
go mod download go mod download
echo "bootstrap complete" echo "bootstrap complete"
+24 -1
View File
@@ -4,11 +4,34 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" 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() { main() {
cd "$ROOT" cd "$ROOT"
gofmt -s -w . gofmt -s -w .
goimports -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 "$@" main "$@"
+14 -2
View File
@@ -1,12 +1,24 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. # script/lint: run golangci-lint. The linter is never installed on the
# host; it runs only inside Docker, from the pinned image in
# Dockerfile.lint, so every run uses the same linter version everywhere.
# Linting is a build step there, so a successful build is a clean lint.
#
# GATE_RUN differs every run so the lint layer always executes; a cached
# build would otherwise exit 0 in under a second having linted nothing.
# --output=type=cacheonly discards the image and keeps only build cache,
# so no tagged image is left behind.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
golangci-lint run --config .golangci.yml ./... docker build \
--build-arg GATE_RUN="$(date +%s)-$$" \
--output=type=cacheonly \
-f Dockerfile.lint \
.
} }
main "$@" main "$@"
+8
View File
@@ -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==