feat(phase 3p): bake git SHA into binary + surface on /healthz
Closes the silent-deploy-rot gap caught by Phase 3n's triage. The problem: a missing Gitea webhook left 11 commits stuck on an old container while /healthz kept reporting 200 from the stale binary. With no commit-level evidence on the wire, "deploy rolled" was unverifiable. Mechanism: - Dockerfile installs git, reads `git rev-parse --short HEAD` at build time, injects via `-ldflags="-X main.gitCommit=<sha>"`. Works under Dokploy's `git clone --depth 1` flow (the .git/ folder is in the build context) and under plain `docker build .` (same). Local `go run` falls back to "unknown". - main.gitCommit assigns to web.Server.Version in main(). - /healthz now emits two lines: "ok" and "version: <sha>". Endpoint remains unauthenticated so any worker / monitor can verify "deploy rolled" without a session. CLAUDE.md gets a mandatory "Post-deploy verification" section: after every push, compare `git rev-parse --short HEAD` against `curl /healthz | tail -1`. Mismatch = webhook broken; inspect Gitea hook 172 (URL pattern `http://mlake.horse-ayu.ts.net:3000/api/deploy/ <refreshToken>` per the working webhooks on m/msbls.de + m/flexsiebels.de). TestHealthzSurfacesVersion regression-guards the new line. Existing TestHealthz updated to accept the multi-line body.
This commit is contained in:
11
CLAUDE.md
11
CLAUDE.md
@@ -40,6 +40,17 @@ docs/design.md PRD — the source of truth for behaviour
|
|||||||
- No `dev` branch initially (small project)
|
- No `dev` branch initially (small project)
|
||||||
- `--no-ff` merges to main
|
- `--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: <sha>"
|
||||||
|
```
|
||||||
|
|
||||||
|
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/<refreshToken>`. The healthz endpoint exposes `Server.Version` (populated from `main.gitCommit` via Dockerfile-time `-ldflags="-X main.gitCommit=..."` reading `git rev-parse --short HEAD`).
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- **PRD landed** (`docs/design.md`, 2026-05-15) — schema, lifecycle, interface contracts settled.
|
- **PRD landed** (`docs/design.md`, 2026-05-15) — schema, lifecycle, interface contracts settled.
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -1,11 +1,20 @@
|
|||||||
# syntax=docker/dockerfile:1.6
|
# syntax=docker/dockerfile:1.6
|
||||||
|
|
||||||
FROM golang:1.25-alpine AS build
|
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
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
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
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
COPY --from=build /out/projax /projax
|
COPY --from=build /out/projax /projax
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ import (
|
|||||||
"github.com/m/projax/web"
|
"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() {
|
func main() {
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
|
||||||
@@ -63,6 +70,8 @@ func main() {
|
|||||||
logger.Error("server init", "err", err)
|
logger.Error("server init", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
srv.Version = gitCommit
|
||||||
|
logger.Info("startup", "version", gitCommit)
|
||||||
|
|
||||||
if supaURL := os.Getenv("SUPABASE_URL"); supaURL != "" {
|
if supaURL := os.Getenv("SUPABASE_URL"); supaURL != "" {
|
||||||
anon := os.Getenv("SUPABASE_ANON_KEY")
|
anon := os.Getenv("SUPABASE_ANON_KEY")
|
||||||
|
|||||||
@@ -263,7 +263,11 @@ func (s *Server) Routes() http.Handler {
|
|||||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
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.Fprintln(w, "ok")
|
||||||
|
fmt.Fprintf(w, "version: %s\n", s.Version)
|
||||||
})
|
})
|
||||||
|
|
||||||
if s.MCP != nil {
|
if s.MCP != nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestHealthz(t *testing.T) {
|
||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
code, body := get(t, srv.Routes(), "/healthz")
|
code, body := get(t, srv.Routes(), "/healthz")
|
||||||
if code != 200 || strings.TrimSpace(body) != "ok" {
|
// Body is two lines now (Phase 3p): "ok\nversion: <sha>\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)
|
t.Fatalf("healthz: %d %q", code, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user