diff --git a/CLAUDE.md b/CLAUDE.md index 8643a74..0d3e89e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,17 @@ docs/design.md PRD — the source of truth for behaviour - No `dev` branch initially (small project) - `--no-ff` merges to main +## Post-deploy verification (mandatory) + +After every `git push origin main`, verify the new binary actually rolled — do NOT trust `/healthz: ok` alone. The pre-3p Phase 3n triage caught 11 commits silently stuck on an old container because the Gitea webhook was missing and healthz kept reporting 200 from the stale binary. The check: + +```sh +git rev-parse --short HEAD # what you just pushed +curl -s https://projax.msbls.de/healthz | tail -1 # "version: " +``` + +If the SHAs match, the deploy rolled. If they don't, the webhook is broken — inspect `https://mgit.msbls.de/api/v1/repos/m/projax/hooks` (curl --netrc) and confirm hook id 172 exists pointing at `http://mlake.horse-ayu.ts.net:3000/api/deploy/`. The healthz endpoint exposes `Server.Version` (populated from `main.gitCommit` via Dockerfile-time `-ldflags="-X main.gitCommit=..."` reading `git rev-parse --short HEAD`). + ## Status - **PRD landed** (`docs/design.md`, 2026-05-15) — schema, lifecycle, interface contracts settled. diff --git a/Dockerfile b/Dockerfile index a65177e..43a6840 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,20 @@ # syntax=docker/dockerfile:1.6 FROM golang:1.25-alpine AS build +# git is needed at build time to read the commit SHA. Dokploy clones the +# source with --depth 1 so .git/ is present inside the build context after +# the COPY below — `git rev-parse` resolves the actual commit being built. +# No build-arg orchestration needed; any environment that ships .git/ +# alongside source gets the right value. +RUN apk add --no-cache git WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/projax ./cmd/projax +RUN GIT_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" && \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.gitCommit=${GIT_COMMIT}" \ + -o /out/projax ./cmd/projax FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/projax /projax diff --git a/cmd/projax/main.go b/cmd/projax/main.go index de9c14b..37a4422 100644 --- a/cmd/projax/main.go +++ b/cmd/projax/main.go @@ -20,6 +20,13 @@ import ( "github.com/m/projax/web" ) +// gitCommit is the short SHA of HEAD at build time, injected via +// -ldflags="-X main.gitCommit=...". Defaults to "unknown" so local `go run` +// without ldflags still works. Surfaced on /admin's system panel so every +// shift can verify which commit is actually running — closes the silent +// deploy-rot gap from Phase 3n's triage. +var gitCommit = "unknown" + func main() { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) @@ -63,6 +70,8 @@ func main() { logger.Error("server init", "err", err) os.Exit(1) } + srv.Version = gitCommit + logger.Info("startup", "version", gitCommit) if supaURL := os.Getenv("SUPABASE_URL"); supaURL != "" { anon := os.Getenv("SUPABASE_ANON_KEY") diff --git a/web/server.go b/web/server.go index 04f446b..f8b830e 100644 --- a/web/server.go +++ b/web/server.go @@ -263,7 +263,11 @@ func (s *Server) Routes() http.Handler { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } + // Surface the build-time git SHA so any worker can verify "deploy + // rolled" without needing an authed session. Body is two + // human-readable lines so curl piped to head still reads cleanly. fmt.Fprintln(w, "ok") + fmt.Fprintf(w, "version: %s\n", s.Version) }) if s.MCP != nil { diff --git a/web/server_test.go b/web/server_test.go index 46c2aa8..2e6ed50 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -101,11 +101,36 @@ func TestLayoutHasViewportMeta(t *testing.T) { } } +// TestHealthzSurfacesVersion proves /healthz returns the version line as +// well as the ok marker. Phase 3p — closes the silent-deploy-rot gap so a +// worker can verify "deploy actually rolled" with an unauthenticated curl +// (compare against `git rev-parse --short HEAD` before assuming the latest +// merge is live). +func TestHealthzSurfacesVersion(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + srv.Version = "abc1234" + h := srv.Routes() + code, body := get(t, h, "/healthz") + if code != 200 { + t.Fatalf("GET /healthz → %d", code) + } + if !strings.Contains(body, "ok") { + t.Errorf("body should contain 'ok', got %q", body) + } + if !strings.Contains(body, "version: abc1234") { + t.Errorf("body should contain 'version: abc1234', got %q", body) + } +} + func TestHealthz(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() code, body := get(t, srv.Routes(), "/healthz") - if code != 200 || strings.TrimSpace(body) != "ok" { + // Body is two lines now (Phase 3p): "ok\nversion: \n". Assert the + // 200 status + "ok" leader, not exact equality, so the version line can + // grow without breaking this guard. + if code != 200 || !strings.HasPrefix(body, "ok") { t.Fatalf("healthz: %d %q", code, body) } }