The classic builder left an untagged image for every step of every
stage of a multi-stage build. BuildKit keeps stages in Docker's build
cache, which Docker keeps under a size limit on its own. BuildKit
reports progress as encoded trace messages, so these are decoded with
BuildKit's own library and written to the deployment log as plain text.
The README notes that engines older than 28.2 need build cache cleanup
turned on in daemon.json.
Model: opus-5-5
Every deploy built and tagged a new image and nothing removed the old ones, so disk use grew with each push. After a successful deploy, upaas now removes the app's old `upaas-<app>:<N>` tags by name, without force, keeping the current image and the previous one that rollback starts. Docker deletes an image only when no other tag or container still uses it, so an image shared with another app stays. A failed removal is a warning in the deployment log, not a failed deploy. The post-deploy step is one function, tested against a fake Docker API.
Judgement call: untagged images from other stages of a multi-stage build stay; they are the build cache.
On the first deploy after upgrading, all older images of that app are removed at once.
Model: opus-5-5
The pinned `alpine/git` image declares a volume at `/git`, so every clone container got an anonymous volume, and the container was removed without its volumes, leaving one volume behind per deploy. The clone container is now removed together with its volumes, whether the clone succeeds, fails or is cancelled; the removal uses a context that outlives cancellation. Tests cover success, failure and cancellation against a fake Docker API, and a manual check against real Docker is recorded on the PR. The old app container's own anonymous volumes are still kept on redeploy, since deleting them could discard app data.
Model: opus-5-5
Deployment logs were stored under a directory named after the container's hostname, and the path was worked out again from the current hostname on download. Docker gives a recreated container a new hostname, so every older log download returned 404. New logs now go to `logs/<appname>/` with no hostname in the path. Logs written by older versions under an old hostname directory are still found by looking one directory deeper, inside the same confined log root. Tests cover both the hostname-free path and downloading an old-layout log.
Model: opus-5-5
`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
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)
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
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
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
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
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
- CSRF over plain HTTP (#189): gorilla/csrf assumed https for its
same-origin check, so setup and every POST returned 403 over plain
HTTP. Gate csrf.PlaintextHTTPRequest on a new UPAAS_PLAINTEXT_HTTP
config value; the default keeps https, correct for a TLS-terminating
reverse proxy. The README plain-HTTP recipe now sets it.
- git image never pulled (#190): ensureImage pulls alpine/git (pinned
digest unchanged) when absent, before the clone container is created.
- port-mapping 500 (#192): the ports delete form used {{ .CSRFField }}
inside {{range .Ports}}, where the dot is a *models.Port; use
{{ $.CSRFField }} like the labels and volumes blocks.
- env-var 403 (#191): the editor read the CSRF token from $el (the
submitting form, which has none) instead of $root, sending an empty
token; read from $root.
Model: opus-4-8
Bumps golangci-lint from v2.10.1 to v2.12.2 everywhere it is pinned and installs the canonical `.golangci.yml`, then fixes every finding the new linter surfaces so `make check` is green.
## Version pins
- `Dockerfile` lint stage: `golangci/golangci-lint:v2.12.2` (Debian-based), tag plus digest pin
- `script/bootstrap`: `GOLANGCI_LINT_VERSION=2.12.2` with updated `linux-amd64`/`linux-arm64` release-archive sha256 pins
## Config
`.golangci.yml` replaced with the canonical config. Material change: the old file declared `version: "2"` but kept settings under the legacy top-level `linters-settings` key, which golangci-lint v2 ignores — so the intended thresholds (`lll` 88, `funlen` 80/50, `cyclop` 15, `dupl` 100) were not being applied. The canonical file moves them under `linters.settings` and drops `issues.exclude-use-default`.
## Lint fixes (216 findings)
- `lll` (96): wrapped lines to the 88-column limit
- `noctx` (46): `httptest.NewRequestWithContext` with `t.Context()` throughout the tests
- `goconst` (24): shared constants for template/JSON keys in `internal/handlers` and repeated test literals
- `gosec` (23): app-page redirects now go through a `redirectToApp` helper that path-escapes the app ID (G710 open redirect); `http.ServeFile` of the internally derived deployment log path annotated like the adjacent `os.Stat` (G703)
- `dupl` (22): extracted a generic `findAllByAppID` in `internal/models`, a `deleteAppResource` helper in `internal/handlers`, a shared `parsePush` in `internal/service/webhook`, and table-driven/helper-based dedup in tests
- `nolintlint` (5): removed `//nolint:funlen` directives made obsolete by the new limits (plus one more that became obsolete after refactoring)
- `nilerr` (3, surfaced during fixing): resource-delete lookups now propagate the find error to the caller
No behavior changes intended; all tests pass and `make check` is green.
Note: golangci-lint v2.12 warns that `gomodguard` is deprecated in favor of `gomodguard_v2` — a future canonical-config update should address this centrally.
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #187
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>