feat(ops): health reports which web bundle is served, and install-refresh warns when it is not a clean build of the commit (TASK-3233) (#1583)

The binary's commit stamp says nothing about web/build: it is generated,
gitignored and embedded as it stands. A bundle built from uncommitted edits
(a negative-control build, BUG-3129) or left over from an older commit
therefore ships under a clean commit stamp, and nothing afterwards could say
which bundle a server served.

- The server digests the web FS it embeds (sha256 over each file's path and
  content hash) once in SetWebUI. /api/v1/health reports it as an additive
  `web: {sha256, files, source_commit?, source_dirty?}`.
- `vite build` writes build-source.json (the commit, and the count of
  `git status` entries under web/) into static/, which SvelteKit copies into
  the bundle. It never fails a build: with no git, both fields are null
  ("unknown"), never clean. The stamp is gitignored.
- install-refresh.sh step 7 logs the served bundle and WARNS when the stamp
  is missing, when it was built with web/ dirty, or when web/ changed
  between the stamp's commit and the expected one. A backend-only commit on
  top of a bundle is correct and is not flagged. It warns after the
  restart; it does not prevent it.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
This commit is contained in:
xarmian
2026-09-26 16:54:20 -04:00
committed by GitHub
parent bbf09ee4bb
commit bf0631d22f
9 changed files with 334 additions and 2 deletions
+3
View File
@@ -97,6 +97,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp["build_time"] = s.buildTime
}
resp["cloud_mode"] = s.cloudMode
if s.webIdentity != nil {
resp["web"] = s.webIdentity
}
writeJSON(w, http.StatusOK, resp)
}
+2
View File
@@ -78,6 +78,7 @@ type Server struct {
streamGaugeFor *metrics.Metrics // the metrics instance pad_stream_connections_active is registered on (BUG-2726)
httpServer *http.Server // underlying HTTP server (set during ListenAndServe)
webFS fs.FS // embedded web UI static files (optional)
webIdentity *webBuildIdentity // what webFS is, computed once in SetWebUI (TASK-3233)
events events.EventBus // real-time event bus (optional)
publishFailures publishFailureLog // rate-bounds publishActivityEvent's failure log (BUG-2732)
watchEvents watchevents.Bus // watch/nudge notification bus (optional, TASK-2533)
@@ -2173,6 +2174,7 @@ func (s *Server) setupRouter() {
// SetWebUI sets the embedded web UI filesystem for serving the SPA.
func (s *Server) SetWebUI(fsys fs.FS) {
s.webFS = fsys
s.webIdentity = identifyWebBuild(fsys)
s.ensureRouter()
s.router.Handle("/*", s.spaHandler())
}
+86
View File
@@ -0,0 +1,86 @@
package server
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"io/fs"
"log/slog"
)
// webBuildSourceFile is the stamp `vite build` writes into the bundle
// (web/vite.config.ts, buildSourceStamp): the commit the web source was at and
// how many entries `git status` listed under web/ when it ran.
const webBuildSourceFile = "build-source.json"
// webBuildIdentity says which web bundle this binary serves (TASK-3233).
//
// The commit a binary is stamped with says nothing about its web/build: that
// directory is generated, gitignored, and embedded as it stands, so a bundle
// left over from another commit, or built while web/ held uncommitted edits (a
// negative-control build, BUG-3129), ships under the same commit stamp.
// SHA256 is the bundle's own identity, computed from the bytes served, so it
// needs no trust in the build machine. The Source* fields come from the stamp
// and say where the bundle came from; they are absent when it has none.
type webBuildIdentity struct {
SHA256 string `json:"sha256"`
Files int `json:"files"`
SourceCommit *string `json:"source_commit,omitempty"`
SourceDirty *int `json:"source_dirty,omitempty"`
}
type webBuildSource struct {
Commit *string `json:"commit"`
Dirty *int `json:"dirty"`
}
// identifyWebBuild digests every regular file in fsys: sha256 over, in
// WalkDir's lexical order, each file's path, a NUL, its content's sha256, and
// a newline. Paths are part of it, so a rename changes the digest too. A
// failure is logged and leaves the identity unknown (nil); it never stops the
// server, since serving works without it.
func identifyWebBuild(fsys fs.FS) *webBuildIdentity {
if fsys == nil {
return nil
}
h := sha256.New()
files := 0
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.Type().IsRegular() {
return nil
}
f, err := fsys.Open(path)
if err != nil {
return err
}
defer f.Close()
fh := sha256.New()
if _, err := io.Copy(fh, f); err != nil {
return err
}
io.WriteString(h, path)
h.Write([]byte{0})
io.WriteString(h, hex.EncodeToString(fh.Sum(nil)))
h.Write([]byte{'\n'})
files++
return nil
})
if err != nil {
slog.Warn("web build identity: could not digest the embedded web UI", "error", err)
return nil
}
id := &webBuildIdentity{SHA256: hex.EncodeToString(h.Sum(nil)), Files: files}
if raw, err := fs.ReadFile(fsys, webBuildSourceFile); err == nil {
var src webBuildSource
if err := json.Unmarshal(raw, &src); err != nil {
slog.Warn("web build identity: unreadable "+webBuildSourceFile, "error", err)
} else {
id.SourceCommit, id.SourceDirty = src.Commit, src.Dirty
}
}
return id
}
+105
View File
@@ -0,0 +1,105 @@
package server
import (
"net/http"
"testing"
"testing/fstest"
)
func webFS(files map[string]string) fstest.MapFS {
m := fstest.MapFS{}
for p, c := range files {
m[p] = &fstest.MapFile{Data: []byte(c)}
}
return m
}
func TestIdentifyWebBuild_DigestTracksContentAndPaths(t *testing.T) {
base := map[string]string{
"index.html": "<html></html>",
"_app/immutable/start.js": "console.log(1)",
}
a := identifyWebBuild(webFS(base))
if a == nil || a.SHA256 == "" || a.Files != 2 {
t.Fatalf("identity: %+v", a)
}
if b := identifyWebBuild(webFS(base)); b.SHA256 != a.SHA256 {
t.Errorf("digest is not deterministic: %s vs %s", a.SHA256, b.SHA256)
}
changed := map[string]string{"index.html": "<html></html>", "_app/immutable/start.js": "console.log(2)"}
if c := identifyWebBuild(webFS(changed)); c.SHA256 == a.SHA256 {
t.Error("a content change did not change the digest")
}
renamed := map[string]string{"index.html": "<html></html>", "_app/immutable/start2.js": "console.log(1)"}
if r := identifyWebBuild(webFS(renamed)); r.SHA256 == a.SHA256 {
t.Error("a path change did not change the digest")
}
if a.SourceCommit != nil || a.SourceDirty != nil {
t.Errorf("no stamp, but source fields set: %+v", a)
}
}
func TestIdentifyWebBuild_ReadsStamp(t *testing.T) {
id := identifyWebBuild(webFS(map[string]string{
"index.html": "x",
webBuildSourceFile: `{"commit":"abc123","dirty":2}`,
}))
if id.SourceCommit == nil || *id.SourceCommit != "abc123" {
t.Errorf("source_commit: %v", id.SourceCommit)
}
if id.SourceDirty == nil || *id.SourceDirty != 2 {
t.Errorf("source_dirty: %v", id.SourceDirty)
}
// A stamp written where git was unavailable carries nulls: the fields
// stay absent rather than claiming a clean tree at no commit.
unknown := identifyWebBuild(webFS(map[string]string{
"index.html": "x",
webBuildSourceFile: `{"commit":null,"dirty":null}`,
}))
if unknown.SourceCommit != nil || unknown.SourceDirty != nil {
t.Errorf("null stamp should leave source fields absent: %+v", unknown)
}
}
func TestHealth_ReportsServedWebBundle(t *testing.T) {
srv := testServer(t)
srv.SetWebUI(webFS(map[string]string{
"index.html": "<html></html>",
webBuildSourceFile: `{"commit":"abc123","dirty":0}`,
}))
rr := doRequest(srv, http.MethodGet, "/api/v1/health", nil)
if rr.Code != http.StatusOK {
t.Fatalf("health: %d", rr.Code)
}
var resp struct {
Web *struct {
SHA256 string `json:"sha256"`
Files int `json:"files"`
SourceCommit *string `json:"source_commit"`
SourceDirty *int `json:"source_dirty"`
} `json:"web"`
}
parseJSON(t, rr, &resp)
if resp.Web == nil {
t.Fatalf("health has no web member: %s", rr.Body.String())
}
if resp.Web.SHA256 != srv.webIdentity.SHA256 || resp.Web.Files != 2 {
t.Errorf("web: %+v", resp.Web)
}
if resp.Web.SourceCommit == nil || *resp.Web.SourceCommit != "abc123" || resp.Web.SourceDirty == nil || *resp.Web.SourceDirty != 0 {
t.Errorf("web source: %+v", resp.Web)
}
}
func TestHealth_NoWebUI_NoWebMember(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, http.MethodGet, "/api/v1/health", nil)
var resp map[string]interface{}
parseJSON(t, rr, &resp)
if _, ok := resp["web"]; ok {
t.Errorf("health reported a web bundle with no web UI set: %v", resp["web"])
}
}
+35
View File
@@ -893,3 +893,38 @@ note "server restarted and answering on: ${probe_hosts[*]} (port $PORT)"
if [ -n "${unattributed:-}" ]; then
note "listener ownership NOT verified: neither ss nor lsof can see listeners here, so the process answering was not confirmed to be the restart (pid $RESTART_PID)"
fi
# --- 7. Report WHICH web bundle is served, and warn when it is not a clean
# build of this commit (TASK-3233) ---------------------------------------
# The binary's commit stamp says nothing about web/build: it is generated,
# gitignored and embedded as it stands, so a bundle built from uncommitted
# edits (a negative-control build, BUG-3129) or left over from an older
# commit ships under a clean commit stamp. The server digests the bundle it
# embeds and reports it in /api/v1/health, with the source stamp `vite build`
# writes (commit, and entries `git status` listed under web/). This step only
# WARNS: the restart has already happened, and the fix is to rebuild web and
# refresh again. A server older than TASK-3233 reports no `web` member.
warn() { printf 'install-refresh: WARNING: %s\n' "$*" >&2; }
health_json="$(curl -fsS -m 2 "$(probe_url "${probe_hosts[0]}" "$PORT")" 2>/dev/null || true)"
web_json="$(printf '%s' "$health_json" | sed -n 's/.*"web":{\([^}]*\)}.*/\1/p')"
if [ -z "$web_json" ]; then
note "served web bundle: not reported (the server predates TASK-3233, or has no web UI)"
else
web_sha="$(printf '%s' "$web_json" | sed -n 's/.*"sha256":"\([0-9a-f]*\)".*/\1/p')"
web_commit="$(printf '%s' "$web_json" | sed -n 's/.*"source_commit":"\([0-9a-f]*\)".*/\1/p')"
web_dirty="$(printf '%s' "$web_json" | sed -n 's/.*"source_dirty":\([0-9]*\).*/\1/p')"
note "serving web bundle sha256 ${web_sha:-?} (built from ${web_commit:-unknown commit}, web/ dirty ${web_dirty:-unknown})"
if [ -z "$web_commit" ]; then
warn "the web bundle carries no source stamp, so where it was built from is unknown (built without git, or before TASK-3233)."
elif [ -n "$EXPECT_COMMIT" ] && ! commit_matches "$EXPECT_COMMIT" "$web_commit"; then
# A bundle stamped with an older commit is correct when web/ did not
# change in between (a backend-only commit on top). Warn only when it
# did, or when that cannot be established.
if ! git diff --quiet "$web_commit" "$EXPECT_COMMIT" -- web/ 2>/dev/null; then
warn "the web bundle was built at ${web_commit}, and web/ differs between that commit and ${EXPECT_COMMIT} (or that cannot be checked here): web/build is stale. Rebuild web (vite build) and refresh again."
fi
fi
if [ -n "$web_dirty" ] && [ "$web_dirty" != "0" ]; then
warn "the web bundle was built while web/ had ${web_dirty} uncommitted change(s): it is not a build of any commit. Rebuild web from a clean tree and refresh again."
fi
fi
+3
View File
@@ -26,3 +26,6 @@ vite.config.ts.timestamp-*
/playwright-report
/test-results
/e2e/.auth
# Build source stamp written by vite build (TASK-3233); copied into build/.
/static/build-source.json
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { buildSourceStamp, type GitRunner } from './buildSourceStamp';
function runner(outputs: Record<string, string | Error>): GitRunner {
return (args) => {
const out = outputs[args[0]];
if (out instanceof Error) throw out;
if (out === undefined) throw new Error(`unexpected git ${args.join(' ')}`);
return out;
};
}
describe('buildSourceStamp (TASK-3233)', () => {
it('a clean tree stamps the commit and zero', () => {
expect(buildSourceStamp(runner({ 'rev-parse': 'abc123\n', status: '' }))).toEqual({ commit: 'abc123', dirty: 0 });
});
it('counts each porcelain entry under web/', () => {
const status = ' M src/lib/components/items/CopyItemDialog.svelte\n?? src/new.ts\n';
expect(buildSourceStamp(runner({ 'rev-parse': 'abc123', status }))).toEqual({ commit: 'abc123', dirty: 2 });
});
it('no git: both fields null (unknown, never clean)', () => {
expect(buildSourceStamp(runner({ 'rev-parse': new Error('not a git repository') }))).toEqual({
commit: null,
dirty: null
});
});
it('a failing status leaves dirty unknown, not zero', () => {
expect(buildSourceStamp(runner({ 'rev-parse': 'abc123', status: new Error('boom') }))).toEqual({
commit: 'abc123',
dirty: null
});
});
});
+41
View File
@@ -0,0 +1,41 @@
// The web bundle's source stamp (TASK-3233): which commit `vite build` ran at,
// and how many entries `git status` listed under web/ at that moment. The
// server reads it out of the embedded bundle and reports it in
// /api/v1/health next to the served bundle's own digest, so a bundle built
// from uncommitted edits (a negative-control build, BUG-3129) or left over
// from another commit is visible after it ships under a clean commit stamp.
//
// It is written into static/ at buildStart, which SvelteKit copies into the
// build output. It never fails a build: with no git (a Nix sandbox, a
// tarball) both fields are null, which reads as "unknown", never as clean.
export const BUILD_SOURCE_FILE = 'build-source.json';
export interface BuildSource {
commit: string | null;
dirty: number | null;
}
/** Runs git with the given args in the web/ directory and returns stdout. */
export type GitRunner = (args: string[]) => string;
export function buildSourceStamp(git: GitRunner): BuildSource {
let commit: string | null = null;
let dirty: number | null = null;
try {
commit = git(['rev-parse', 'HEAD']).trim() || null;
} catch {
commit = null;
}
if (commit !== null) {
try {
// `-- .` scopes to web/. Ignored paths (node_modules, build,
// .svelte-kit, this stamp) do not count.
const out = git(['status', '--porcelain', '--', '.']);
dirty = out.split('\n').filter((l) => l.trim() !== '').length;
} catch {
dirty = null;
}
}
return { commit, dirty };
}
+23 -2
View File
@@ -1,8 +1,29 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
import { execFileSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { BUILD_SOURCE_FILE, buildSourceStamp } from './src/lib/build/buildSourceStamp';
const WEB_DIR = fileURLToPath(new URL('.', import.meta.url));
// Stamp the bundle with where it was built from (TASK-3233); see
// buildSourceStamp.ts. Build only: the dev server never needs it.
function buildSource(): Plugin {
return {
name: 'pad-build-source',
apply: 'build',
buildStart() {
const stamp = buildSourceStamp((args) =>
execFileSync('git', args, { cwd: WEB_DIR, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
);
writeFileSync(new URL(`./static/${BUILD_SOURCE_FILE}`, import.meta.url), JSON.stringify(stamp) + '\n');
}
};
}
export default defineConfig({
plugins: [sveltekit()],
plugins: [buildSource(), sveltekit()],
server: {
proxy: {
'/api': {