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).
* chore: wire HOMEBREW_TAP_GITHUB_TOKEN into release pipeline
The brews block in .goreleaser.yaml targets the separate xarmian/homebrew-tap
repo. Without a token override, goreleaser falls back to the workflow's
GITHUB_TOKEN — which is scoped to xarmian/pad only and cannot push to the
tap repo. At first real tag time the brew publish step would fail with a
permission error.
- Add `repository.token` to the brews block, referencing
`{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}`
- Export `HOMEBREW_TAP_GITHUB_TOKEN` from the workflow `secrets` into the
goreleaser step env, alongside the existing `GITHUB_TOKEN`
- Comment both edits with the rationale + the fine-grained PAT permissions
the secret needs (`Contents: write` + `Metadata: read` on the tap repo)
The secret itself is created on the human side (HT-780). Snapshot mode
skips publishing so this isn't testable locally — the gate is HT-782's
v0.0.1-rc.1 dress rehearsal.
Refs: TASK-806, TASK-778 (audit), HT-780 (operator step)
* chore: migrate goreleaser deprecations (archives.formats, dockers_v2, homebrew_casks)
Three v2 deprecations were flagged by `goreleaser check` while wiring the
tap token. Migrating them now (instead of filing tech debt) because we're
already touching the file and these are part of the same release pipeline
that ships at v0.1.0 — no point landing a "wire the token" commit that
still trips deprecation warnings on the very next CI run.
Changes:
- archives: `format: tar.gz` + `format_overrides[].format: zip` →
`formats: ["tar.gz"]` + `format_overrides[].formats: ["zip"]`
(single-string is still accepted but the list form is the new spec)
- dockers + docker_manifests → dockers_v2:
Single block with `images:` + `tags:` + `platforms:` collapses the
prior per-architecture builds plus separate manifest declarations into
one declaration. buildx + multi-platform are implicit. Snapshot
validates: amd64 + arm64 images both build, manifest list assembled,
binary runs inside the cross-built image.
goreleaser flags dockers_v2 as "experimental and subject to change" —
it's the documented forward path for v2 and the project is already
pinned to `version: "~> v2"`, so we're committed to the roadmap.
- Dockerfile.goreleaser: add `ARG TARGETPLATFORM` and update the COPY to
`${TARGETPLATFORM}/pad`. dockers_v2 organizes pre-built binaries under
`linux/amd64/pad`, `linux/arm64/pad` etc; buildx populates
TARGETPLATFORM per platform during the build.
- brews → homebrew_casks: `directory: Formula` → `directory: Casks`.
The `brews` keyword is fully phased out in v2.10+; goreleaser's
homebrew_casks now natively handles pre-compiled binaries (which used
to require workarounds with the old brews block). End-user UX is
unchanged: `brew install xarmian/tap/pad` works identically because
modern Homebrew auto-detects whether a tap entry is a formula or a
cask. Removed the no-op `test:` stanza that doesn't apply to casks.
Validation: `goreleaser check` clean (zero warnings), `goreleaser
release --snapshot --clean --skip=publish,sign,sbom` builds all six
binaries, six archives, one cask, two cross-platform docker images.
`docker run --rm ghcr.io/xarmian/pad:latest-amd64 --version` returns
the snapshot version as expected.
Refs: TASK-806
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.
Dockerfile.goreleaser had CMD ["serve"] but the pad binary exposes
its HTTP server under `pad server start` (no "serve" command exists).
As a result, `docker run ghcr.io/xarmian/pad:latest` exited immediately
with "unknown command \"serve\"". Align with Dockerfile which already
uses CMD ["server", "start"].
Parent: PLAN-644.