Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit05cdb85unknown_key

feat(visibility): instant deploy feedback loop — /api/version + footer SHA + auto-update banner

feat(visibility): instant deploy feedback loop — /api/version + footer SHA + auto-update banner

User pain point: 'I can't tell if my change went live.' Lots of waiting,
guessing, hard-refreshing, asking 'is it deployed?' Today's slog made
this acute. Fixing the visibility, not the speed.

Three pieces:

1. src/lib/build-info.ts — reads git HEAD + boot time once at process
   start. Honours GIT_SHA env override for container builds without .git.
   Caches forever; uptime computed at access. Falls back to 'unknown'
   gracefully when .git is missing.

2. src/routes/version.ts — GET /api/version returns:
     { sha, shaFull, branch, builtAt, uptimeMs }
   Cache-control: no-store, no-cache, must-revalidate. Always live.

3. src/views/layout.tsx — three additions wired through the global Layout:
   - Footer SHA stamp: '● 1ba118b · main' in mono with a pulsing green
     dot (visual proof of which commit is serving the page you're on)
   - Hidden #version-banner pill (bottom centre): 'New version available
     — Reload' with a gradient reload button
   - Inline versionPollerScript: fetches /api/version every 15s; once
     the running sha changes from what the page loaded with, the banner
     reveals. Click reload, you're on the new code. Vercel-style.

Net flow:
   - Push to main -> vultr-deploy fires (~90s) -> new sha live
   - User has the page open -> next 15s poll detects -> banner appears
   - Click reload -> new code immediately

For me from this conversation:
   - Push -> curl https://gluecron.com/api/version
   - Confirm sha matches my push -> tell user 'live, footer reads <sha>'
   - No more 'wait and refresh and clear cache' guesswork

Tests: 1236 pass / 1 unrelated. New version-endpoint.test.ts adds 3
cases covering shape + uptime monotonicity + cache headers.
Claude committed on May 4, 2026Parent: c963db5
5 files changed+232105cdb85fd67c06962ffc5bea86a973aa0a38692a
5 changed files+232−1
Addedsrc/__tests__/version-endpoint.test.ts+37−0View fileUnifiedSplit
1/**
2 * /api/version smoke + build-info shape tests.
3 */
4import { describe, expect, it } from "bun:test";
5import { Hono } from "hono";
6import versionRoutes from "../routes/version";
7import { getBuildInfo } from "../lib/build-info";
8
9describe("/api/version", () => {
10 const app = new Hono();
11 app.route("/", versionRoutes);
12
13 it("returns 200 + JSON with sha + uptimeMs + builtAt", async () => {
14 const res = await app.request("/api/version");
15 expect(res.status).toBe(200);
16 expect(res.headers.get("cache-control") || "").toContain("no-store");
17 const body = (await res.json()) as Record<string, unknown>;
18 expect(typeof body.sha).toBe("string");
19 expect(typeof body.shaFull).toBe("string");
20 expect(typeof body.branch).toBe("string");
21 expect(typeof body.builtAt).toBe("string");
22 expect(typeof body.uptimeMs).toBe("number");
23 expect((body.sha as string).length).toBeGreaterThan(0);
24 });
25
26 it("returns short sha (7 chars or 'unknown')", () => {
27 const b = getBuildInfo();
28 expect(b.sha === "unknown" || b.sha.length === 7).toBe(true);
29 });
30
31 it("uptimeMs increments between calls", async () => {
32 const a = getBuildInfo().uptimeMs;
33 await new Promise((r) => setTimeout(r, 5));
34 const b = getBuildInfo().uptimeMs;
35 expect(b).toBeGreaterThan(a);
36 });
37});
Modifiedsrc/app.tsx+5−0View fileUnifiedSplit
3333import helpRoutes from "./routes/help";
3434import marketingRoutes from "./routes/marketing";
3535import seoRoutes from "./routes/seo";
36import versionRoutes from "./routes/version";
3637import { platformStatus } from "./routes/platform-status";
3738import insightRoutes from "./routes/insights";
3839import dashboardRoutes from "./routes/dashboard";
245246// SEO: robots.txt + sitemap.xml
246247app.route("/", seoRoutes);
247248
249// /api/version — live build SHA + uptime; client poller uses this to
250// surface 'New version available — reload' banners on deploy.
251app.route("/", versionRoutes);
252
248253// Health dashboard (per-repo health page)
249254app.route("/", healthDashboardRoutes);
250255
Addedsrc/lib/build-info.ts+85−0View fileUnifiedSplit
1/**
2 * Build info — captured once at boot, exposed everywhere.
3 *
4 * Reads git HEAD + boot time on first access so the running process can
5 * tell anyone (the /api/version endpoint, the footer SHA stamp, the
6 * client-side update poller) exactly which commit it's serving.
7 *
8 * Falls back gracefully when the .git directory isn't available
9 * (e.g. running from a Docker image that didn't include it).
10 */
11
12import { spawnSync } from "child_process";
13import { existsSync } from "fs";
14import { join } from "path";
15
16export interface BuildInfo {
17 sha: string; // 7-char short sha
18 shaFull: string; // 40-char full sha
19 branch: string;
20 builtAt: string; // ISO of process boot
21 uptimeMs: number; // computed at access time
22}
23
24const STARTED_AT = Date.now();
25
26let _cached: Omit<BuildInfo, "uptimeMs"> | null = null;
27
28function read(): Omit<BuildInfo, "uptimeMs"> {
29 if (_cached) return _cached;
30
31 // Honour explicit env overrides first (set by the deploy script in
32 // immutable container images that strip the .git directory).
33 const envSha = process.env.GIT_SHA?.trim();
34 const envBranch = process.env.GIT_BRANCH?.trim();
35 if (envSha) {
36 _cached = {
37 sha: envSha.slice(0, 7),
38 shaFull: envSha,
39 branch: envBranch || "main",
40 builtAt: new Date(STARTED_AT).toISOString(),
41 };
42 return _cached;
43 }
44
45 // Otherwise read from local .git
46 const cwd = process.cwd();
47 const hasGit = existsSync(join(cwd, ".git"));
48 if (!hasGit) {
49 _cached = {
50 sha: "unknown",
51 shaFull: "unknown",
52 branch: "unknown",
53 builtAt: new Date(STARTED_AT).toISOString(),
54 };
55 return _cached;
56 }
57
58 try {
59 const sha = spawnSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" });
60 const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8" });
61 const shaFull = (sha.stdout || "").trim();
62 _cached = {
63 sha: shaFull.slice(0, 7) || "unknown",
64 shaFull: shaFull || "unknown",
65 branch: (branch.stdout || "").trim() || "main",
66 builtAt: new Date(STARTED_AT).toISOString(),
67 };
68 } catch {
69 _cached = {
70 sha: "unknown",
71 shaFull: "unknown",
72 branch: "unknown",
73 builtAt: new Date(STARTED_AT).toISOString(),
74 };
75 }
76 return _cached;
77}
78
79export function getBuildInfo(): BuildInfo {
80 const base = read();
81 return {
82 ...base,
83 uptimeMs: Date.now() - STARTED_AT,
84 };
85}
Addedsrc/routes/version.ts+25−0View fileUnifiedSplit
1/**
2 * /api/version — public build-info endpoint.
3 *
4 * Returns the running process's commit SHA, branch, boot time, and uptime
5 * as a tiny JSON payload. Used by:
6 * - The client-side auto-update banner (polls every 15s, prompts reload
7 * when sha changes)
8 * - Operators sanity-checking 'did my push actually deploy?'
9 * - Monitoring (latency to seeing a new sha = end-to-end deploy time)
10 *
11 * Cache-control: no-store. Must be live, never cached.
12 */
13
14import { Hono } from "hono";
15import { getBuildInfo } from "../lib/build-info";
16
17const version = new Hono();
18
19version.get("/api/version", (c) => {
20 c.header("cache-control", "no-store, no-cache, must-revalidate");
21 c.header("pragma", "no-cache");
22 return c.json(getBuildInfo());
23});
24
25export default version;
Modifiedsrc/views/layout.tsx+80−1View fileUnifiedSplit
22import type { User } from "../db/schema";
33import { hljsThemeCss } from "../lib/highlight";
44import { clientJs } from "./client-js";
5import { getBuildInfo } from "../lib/build-info";
56
67export const Layout: FC<
78 PropsWithChildren<{
1213 }>
1314> = ({ children, title, user, notificationCount, theme }) => {
1415 const initialTheme = theme === "light" ? "light" : "dark";
16 const build = getBuildInfo();
1517 return (
1618 <html lang="en" data-theme={initialTheme}>
1719 <head>
134136 </div>
135137 <div class="footer-bottom">
136138 <span>&copy; {new Date().getFullYear()} gluecron</span>
137 <span>shipped with intent · v1</span>
139 <span class="footer-build" title={`commit ${build.shaFull}\nbuilt ${build.builtAt}`}>
140 <span class="footer-build-dot" aria-hidden="true" />
141 {build.sha} · {build.branch}
142 </span>
138143 </div>
139144 </footer>
145 {/* Live update poller — checks /api/version every 15s, prompts
146 reload when the running sha changes. Pure progressive-
147 enhancement; degrades to nothing if JS is off. */}
148 <div
149 id="version-banner"
150 style="display:none;position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:9999;background:var(--bg-elevated);border:1px solid rgba(140,109,255,0.45);border-radius:9999px;padding:8px 14px 8px 14px;font-size:13px;color:var(--text-strong);box-shadow:0 12px 28px -8px rgba(0,0,0,0.55),0 0 24px -6px rgba(140,109,255,0.40);font-family:var(--font-sans);align-items:center;gap:10px"
151 >
152 <span style="display:inline-flex;align-items:center;gap:8px">
153 <span style="width:8px;height:8px;border-radius:50%;background:#34d399;box-shadow:0 0 10px rgba(52,211,153,0.6)" />
154 <span>New version available</span>
155 </span>
156 <button
157 type="button"
158 id="version-banner-reload"
159 style="background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;border:0;border-radius:9999px;padding:5px 12px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit"
160 >
161 Reload
162 </button>
163 </div>
164 <script>{versionPollerScript}</script>
140165 {/* Block I4 — Command palette shell (hidden by default) */}
141166 <div
142167 id="cmdk-backdrop"
163188 );
164189};
165190
191// Live version poller. Checks /api/version every 15s; if the sha differs
192// from the one we booted with, reveal the floating 'New version' pill so
193// the user can reload onto the new code without manually refreshing.
194const versionPollerScript = `
195 (function(){
196 var loadedSha = null;
197 var banner, btn;
198 function poll(){
199 fetch('/api/version', { cache: 'no-store' })
200 .then(function(r){ return r.ok ? r.json() : null; })
201 .then(function(j){
202 if (!j || !j.sha) return;
203 if (loadedSha === null) { loadedSha = j.sha; return; }
204 if (j.sha !== loadedSha && banner) {
205 banner.style.display = 'inline-flex';
206 }
207 })
208 .catch(function(){});
209 }
210 function init(){
211 banner = document.getElementById('version-banner');
212 btn = document.getElementById('version-banner-reload');
213 if (btn) btn.addEventListener('click', function(){ window.location.reload(); });
214 poll();
215 setInterval(poll, 15000);
216 }
217 if (document.readyState === 'loading') {
218 document.addEventListener('DOMContentLoaded', init);
219 } else {
220 init();
221 }
222 })();
223`;
224
166225// Runs before paint — reads the theme cookie and flips data-theme so there's
167226// no dark-to-light flash on load. SSR default is dark.
168227const themeInitScript = `
878937 }
879938 footer .footer-bottom a { color: var(--text-faint); }
880939 footer .footer-bottom a:hover { color: var(--text-muted); text-decoration: none; }
940 footer .footer-build {
941 display: inline-flex;
942 align-items: center;
943 gap: 6px;
944 color: var(--text-faint);
945 font-family: var(--font-mono);
946 font-size: 11px;
947 cursor: help;
948 }
949 footer .footer-build-dot {
950 width: 6px; height: 6px;
951 border-radius: 50%;
952 background: var(--green);
953 box-shadow: 0 0 6px rgba(52,211,153,0.55);
954 animation: footer-build-pulse 2.4s ease-in-out infinite;
955 }
956 @keyframes footer-build-pulse {
957 0%, 100% { opacity: 0.6; }
958 50% { opacity: 1; }
959 }
881960 @media (max-width: 768px) {
882961 footer .footer-inner { grid-template-columns: 1fr; gap: 32px; }
883962 footer .footer-links { grid-template-columns: repeat(2, 1fr); gap: 24px 32px; }
884963