Bumps all four Node version pins from "22" to "24" together so CI ↔
production stay aligned:
• Dockerfile (production image): node:22-alpine → node:24-alpine
• .github/workflows/ci.yml — Web job + E2E job
• .github/workflows/release.yml — release pipeline
Going to LTS-bound 24 instead of dependabot's proposed 25-alpine,
which hits EOL on 2026-06-01 (~3 weeks from this commit). Going to
24 instead of waiting for 26-LTS (Oct 2026) because 5 months is too
long to sit on the deferred-bumps backlog and 24 is already a year
into LTS-tested production use. Worst case follow-up is one more
trivial Dockerfile bump in October.
Verified:
• Local `docker build --target web-builder` on node:24-alpine
(npm ci + npm run build) — clean, 24s end-to-end
• `make check` — golangci-lint + go test + npm run build +
svelte-check, 0 errors
Closes dependabot/docker/node-25-alpine (PR #209) — closing rather
than rebasing because we're going to 24, not 25.
Tiny /bin/sh entrypoint shim that, if invoked as root, reads PUID/PGID
env vars (defaulting to 99/100 — Unraid's nobody:users), remaps the
in-image pad user, chowns /data, and execs the binary via su-exec.
If invoked as non-root (caller passed --user), it just execs directly
— caller knows what they want.
Solves the classic Unraid appdata-ownership-mismatch first-run failure
where the in-image pad user (uid 1000) couldn't write to a host volume
owned by nobody:users (uid 99, gid 100). Reusable on Synology / QNAP /
TrueNAS where the host's appdata user is similarly non-1000.
Behavior changes:
- Container starts as root (USER directive removed). Entrypoint drops
privileges via su-exec before exec'ing pad — standard PUID/PGID
pattern. Healthcheck adapts: root → su-exec to pad; non-root →
direct wget.
- chown -R is always-run (warn-and-continue on per-file failures). A
shallow stat-only check would silently break pad on a restored
backup with mixed-ownership inner files.
- Healthcheck start-period bumped 10s → 60s to absorb slow chown -R
on large attachment stores.
- Compose default 1000/1000 for backward compat with existing deploys
whose volumes were created under the previous USER pad image.
- Raw `docker run` defaults to 99/100 (Unraid convention).
Validation rejects PUID=0 / PGID=0 (would defeat the unprivileged-user
invariant), empty values, and non-numeric values with clear errors.
Goes through 11 rounds of codex pre-implementation design review,
catching:
- gid bug where groupmod alone leaves /etc/passwd's primary-gid stale
- compose $-interpolation gotcha (needs $$( ) not $())
- getent missing from default alpine BusyBox
- shell ${VAR:-} silently masking explicit empty values
- healthcheck running as root after USER drop
- su-exec failing for --user non-root pass-through
Part of PLAN-1166 (Pad on Unraid — Community Apps launch). Unblocks
TASK-1169 (XML template authoring).
The previous build line had:
-X main.commit=$(git rev-parse --short HEAD 2>/dev/null) \
-X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)
The `git rev-parse` ran INSIDE the build container, but .dockerignore
intentionally excludes `.git/` so the working directory has no git
metadata; the `2>/dev/null` swallowed the resulting error and the
substitution silently produced an empty string. Net result: every
Docker build of pad shipped a binary with `commit=""`, which
fullVersion() collapses to just `version` ("dev") with no commit
metadata at all. Caught during Claude Desktop dogfooding when
`pad_meta` returned `pad_version: "dev"` and bug reports had no way
to identify which build they were hitting.
Two ways to fix: add .git/ to the build context (rejected — operator
explicitly does not want .git in the image build), or pre-compute on
the host and pass via --build-arg (this PR). The wrapper that does
the host-side computation lands in pad-cloud separately.
The `date` substitution worked because alpine has `date` in the
builder image, but it had a worse problem: a fresh `date` value on
every build invalidates layer caching for this RUN. Moving to ARG
lets the caller decide cache semantics — production wrappers will
pass a real timestamp; dev rebuilds can omit BUILD_TIME entirely
to keep the cache warm.
Defaults are deliberately ugly-but-honest so a `docker build .`
without args produces "dev (unknown)" rather than hiding the
misconfiguration. Production builds with all three args produce
e.g. "0.1.0-rc.5 (40f636e 2026-05-03T00:15:00Z)".
Sanity-checked all four input combinations (defaults, all-set,
version+commit only, commit-empty) against the existing
fullVersion() logic — output shapes are clean for each.
* chore: bump Go toolchain to 1.26 (TASK-763)
Bump Go from 1.25 to 1.26 across all toolchain pins:
- go.mod — go 1.25.0 → go 1.26.0
- Dockerfile — golang:1.25-alpine → golang:1.26-alpine
- .github/workflows/ci.yml — three setup-go steps (Go, Go-Postgres, E2E jobs)
- .github/workflows/release.yml — release pipeline
No `toolchain` directive: the repo is pre-launch with no external
contributors yet, so we set the floor where we want it (hard requirement).
Verified locally before commit:
- golangci-lint v2.11.4 builds and runs under Go 1.26.2 (pinned in CI)
- golang:1.26-alpine and 1.26.2-alpine images present on Docker Hub
- go build ./... clean
- go vet ./... clean
- go test ./... all pass
Parent: PLAN-644 (OSS Repo Hygiene and Launch Polish).
* chore: gofmt -w under Go 1.26 (TASK-763)
Apply Go 1.26's gofmt to the codebase. ~41 files reformatted, all
struct-tag whitespace realignment — no semantic changes. Verified:
- gofmt -l ./cmd ./internal returns empty after
- go build ./... still clean
- go test ./... still passes (run before commit)
Bundling the gofmt diff with the toolchain bump in the same PR because
the formatting drift is a direct consequence of moving from 1.25 to
1.26; splitting them creates a mandatory two-PR ordering for no value.
Parent: PLAN-644.
* docs: bump documented Go floor to 1.26 (TASK-763)
Match go.mod's hard 1.26.0 requirement in the source-build instructions.
Caught by Codex review round 1 on PR #247.
- README.md:158 — "Go 1.25+" → "Go 1.26+"
- CONTRIBUTING.md:9 — "Go 1.25+" → "Go 1.26+"
First thing external reviewers flag on a public repo: "why does this
image run as root?". Fixes both Dockerfiles and the K8s deployment.
Dockerfiles (Dockerfile + Dockerfile.goreleaser):
- Add a non-login uid:1000 "pad" user via adduser
- chown /data so the app can write its SQLite DB as the unprivileged user
- Declare USER pad before the ENTRYPOINT
deploy/k8s/deployment.yaml:
- Pod-level securityContext: runAsNonRoot, runAsUser/Group 1000, fsGroup
1000 (so the emptyDir volume is group-writable), seccompProfile
RuntimeDefault
- Container-level securityContext: allowPrivilegeEscalation false,
readOnlyRootFilesystem true, drop ALL capabilities
Verified:
- docker build succeeds; `docker inspect ... Config.User` = "pad"
- Container running as uid 1000 serves /api/v1/health successfully
- Container runs with --read-only rootfs + writable /data volume with no
runtime errors (server only writes to /data, never /tmp)
- deploy/k8s/deployment.yaml parses via yq; securityContext block
structurally correct
Parent: PLAN-644.
Before: neither Dockerfile declared HEALTHCHECK. docker-compose.yml
added it at compose level, so "docker run ghcr.io/xarmian/pad:latest"
had no health signal for Docker/Kubernetes/Swarm.
Adds HEALTHCHECK to Dockerfile and Dockerfile.goreleaser probing
GET /api/v1/health (served by internal/server/server.go:353). Uses
wget from busybox (already present in alpine:3.21) so no extra
apk install is needed. Interval 30s / timeout 5s / start-period 10s
/ retries 3 — conservative defaults safe for low-traffic single-user
instances.
Verified the built image reports the expected HEALTHCHECK via
`docker inspect` and that the probe succeeds against a live container.
Parent: PLAN-644.
* Fix all 17 svelte-check warnings across 7 components
- Add tabindex to toolbar role elements (BoardView, Editor)
- Replace nested buttons with div[role=button] in ListView group headers
- Add role="none" to click-to-close backdrop overlays (Editor, slug page)
- Fix label→span for non-input field labels (CreateWorkspaceModal)
- Add keyboard handlers to interactive divs (CreateWorkspaceModal drop zone, slug page modal)
- Remove unused .slash-backdrop CSS (Editor) and input[type=text] selector (CreateWorkspaceModal)
- Fix state_referenced_locally in RawMarkdownEditor ($state init)
- Add svelte-ignore for conditional tabindex false positive (ToastContainer)
* feat: add build version, commit hash, and timestamp to CLI and web UI
Inject version info via ldflags during build (Makefile, GoReleaser,
Dockerfile). Expose version/commit/build_time in the health API
endpoint and display it in the sidebar footer. Dev builds show
"dev (abc1234 ...)", releases show "v1.2.3 (abc1234 ...)".