feat(deploy): notify clients of new releases
CI / Verify (push) Successful in 1m8s
CI / Build immutable images and deploy (push) Has been skipped

This commit is contained in:
2026-08-29 07:13:04 +00:00 Unverified
parent 6fe6d8fd95
commit be095ab236
10 changed files with 162 additions and 1 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ flowchart LR
- PDB with minimum one available replica - PDB with minimum one available replica
- Least-privilege CI deployer RBAC - Least-privilege CI deployer RBAC
- Use the existing external PostgreSQL, Redis, and S3-compatible services through Kubernetes secrets; do not provision an in-cluster database or Redis instance. - Use the existing external PostgreSQL, Redis, and S3-compatible services through Kubernetes secrets; do not provision an in-cluster database or Redis instance.
- Share a stable `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` across replicas and set a deployment ID from the immutable image revision to protect Server Actions and client navigation during rolling updates. - Share a stable `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` across replicas and set a deployment ID from the immutable image revision to protect Server Actions and client navigation during rolling updates. Notify active clients when that revision changes and offer a full reload into the new release.
- Build immutable application and migration images in Gitea Actions, defaulting to `registry.neko-piranha.ts.net/astral/buzz-sheet`. - Build immutable application and migration images in Gitea Actions, defaulting to `registry.neko-piranha.ts.net/astral/buzz-sheet`.
- Run a versioned Drizzle migration Job before rollout. Use expand/contract migrations so the previous application image remains compatible during rolling deployment. - Run a versioned Drizzle migration Job before rollout. Use expand/contract migrations so the previous application image remains compatible during rolling deployment.
- Produce deployment manifests and CI configuration but do not contact or mutate the production cluster during implementation. - Produce deployment manifests and CI configuration but do not contact or mutate the production cluster during implementation.
+4
View File
@@ -225,6 +225,10 @@ NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
across replicas and rolling deployments. Do not place secret values in the across replicas and rolling deployments. Do not place secret values in the
ConfigMap or commit them to this repository. ConfigMap or commit them to this repository.
Every page also subscribes to `/api/active`. When a serving pod reports a newer
deployment ID than the browser's current build, a persistent Thai notification
offers a full reload into the new release.
The base manifests contain deliberate `replace-me` image and deployment values. The base manifests contain deliberate `replace-me` image and deployment values.
For initial bootstrap, the platform operator applies `k8s/base` through an For initial bootstrap, the platform operator applies `k8s/base` through an
environment overlay that supplies an existing immutable image revision. The environment overlay that supplies an existing immutable image revision. The
+16
View File
@@ -0,0 +1,16 @@
import { connection } from "next/server";
import { normalizeDeploymentId } from "@/lib/deployment/version";
import { createEventStream, eventStreamHeaders } from "@/lib/events/sse";
export async function GET(request: Request) {
await connection();
const deploymentId =
normalizeDeploymentId(process.env.NEXT_DEPLOYMENT_ID) ?? "development";
const channel = createEventStream({ signal: request.signal });
channel.sendNamed("deployment", deploymentId);
return new Response(channel.stream, {
headers: eventStreamHeaders(),
});
}
+5
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Anuphan, Geist_Mono } from "next/font/google"; import { Anuphan, Geist_Mono } from "next/font/google";
import { DeploymentUpdateNotifier } from "@/components/deployment-update-notifier";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@@ -26,6 +27,9 @@ export const metadata: Metadata = {
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { export default function RootLayout({ children }: LayoutProps<"/">) {
const deploymentId =
process.env.NEXT_DEPLOYMENT_ID?.trim() || "development";
return ( return (
<html <html
lang="th" lang="th"
@@ -34,6 +38,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<body className="flex min-h-full flex-col"> <body className="flex min-h-full flex-col">
<TooltipProvider> <TooltipProvider>
{children} {children}
<DeploymentUpdateNotifier deploymentId={deploymentId} />
<Toaster position="bottom-right" /> <Toaster position="bottom-right" />
</TooltipProvider> </TooltipProvider>
</body> </body>
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import {
hasDeploymentChanged,
normalizeDeploymentId,
} from "@/lib/deployment/version";
const UPDATE_TOAST_ID = "deployment-update";
export function DeploymentUpdateNotifier({
deploymentId,
}: {
deploymentId: string;
}) {
const currentDeployment = useRef(normalizeDeploymentId(deploymentId));
const updateAvailable = useRef(false);
useEffect(() => {
const source = new EventSource("/api/active");
const receiveDeployment = (event: Event) => {
if (
updateAvailable.current ||
!(event instanceof MessageEvent) ||
typeof event.data !== "string"
) {
return;
}
const nextDeployment = normalizeDeploymentId(event.data);
if (!hasDeploymentChanged(currentDeployment.current, nextDeployment)) {
return;
}
updateAvailable.current = true;
toast("มีอัปเดตใหม่พร้อมใช้งาน", {
id: UPDATE_TOAST_ID,
description: "รีโหลดเพื่ออัปเดตเป็นเวอร์ชันล่าสุด",
action: {
label: "รีโหลด",
onClick: () => window.location.reload(),
},
duration: Infinity,
});
};
source.addEventListener("deployment", receiveDeployment);
return () => {
source.removeEventListener("deployment", receiveDeployment);
source.close();
};
}, []);
return null;
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import {
hasDeploymentChanged,
normalizeDeploymentId,
} from "./version";
describe("deployment version comparison", () => {
it("normalizes identifiers before comparing them", () => {
expect(normalizeDeploymentId(" release-2 ")).toBe("release-2");
expect(normalizeDeploymentId(" ")).toBeNull();
expect(hasDeploymentChanged("release-1", " release-2 ")).toBe(true);
});
it("does not announce the initial or unchanged deployment", () => {
expect(hasDeploymentChanged(null, "release-2")).toBe(false);
expect(hasDeploymentChanged("release-2", "release-2")).toBe(false);
expect(hasDeploymentChanged("release-2", "")).toBe(false);
});
});
+15
View File
@@ -0,0 +1,15 @@
export function normalizeDeploymentId(
value: string | null | undefined,
): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
}
export function hasDeploymentChanged(
current: string | null | undefined,
next: string | null | undefined,
): boolean {
const currentId = normalizeDeploymentId(current);
const nextId = normalizeDeploymentId(next);
return currentId !== null && nextId !== null && currentId !== nextId;
}
+12
View File
@@ -38,10 +38,22 @@ describe("SSE stream", () => {
expect(decoder.decode((await reader.read()).value)).toBe( expect(decoder.decode((await reader.read()).value)).toBe(
'event: invalidation\ndata: {"type":"page.updated","id":"opaque","version":7}\n\n', 'event: invalidation\ndata: {"type":"page.updated","id":"opaque","version":7}\n\n',
); );
channel.sendNamed("deployment", "release-2");
expect(decoder.decode((await reader.read()).value)).toBe(
"event: deployment\ndata: release-2\n\n",
);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
expect(decoder.decode((await reader.read()).value)).toMatch(/^: heartbeat /u); expect(decoder.decode((await reader.read()).value)).toMatch(/^: heartbeat /u);
await vi.advanceTimersByTimeAsync(49); await vi.advanceTimersByTimeAsync(49);
expect((await reader.read()).done).toBe(true); expect((await reader.read()).done).toBe(true);
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
}); });
it("rejects event names that could inject another SSE field", () => {
const channel = createEventStream();
expect(() => channel.sendNamed("deployment\ndata", "release-2")).toThrow(
"SSE event names",
);
channel.close();
});
}); });
+18
View File
@@ -5,6 +5,20 @@ export const SSE_MAX_LIFETIME_MS = 30 * 60 * 1_000;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
function namedEvent(eventName: string, data: string): string {
if (!/^[a-z0-9_-]+$/iu.test(eventName)) {
throw new Error(
"SSE event names may contain only letters, numbers, underscores, and hyphens.",
);
}
const lines = data
.replace(/\r\n?/gu, "\n")
.split("\n")
.map((line) => `data: ${line}`)
.join("\n");
return `event: ${eventName}\n${lines}\n\n`;
}
export interface EventStreamOptions { export interface EventStreamOptions {
signal?: AbortSignal; signal?: AbortSignal;
heartbeatMs?: number; heartbeatMs?: number;
@@ -15,6 +29,7 @@ export interface EventStreamOptions {
export interface EventStreamChannel { export interface EventStreamChannel {
stream: ReadableStream<Uint8Array>; stream: ReadableStream<Uint8Array>;
send(event: InvalidationEvent): boolean; send(event: InvalidationEvent): boolean;
sendNamed(eventName: string, data: string): boolean;
close(): void; close(): void;
} }
@@ -87,6 +102,9 @@ export function createEventStream(
send(event) { send(event) {
return enqueue(`event: invalidation\ndata: ${JSON.stringify(event)}\n\n`); return enqueue(`event: invalidation\ndata: ${JSON.stringify(event)}\n\n`);
}, },
sendNamed(eventName, data) {
return enqueue(namedEvent(eventName, data));
},
close() { close() {
close(true); close(true);
}, },
+14
View File
@@ -110,6 +110,20 @@ describe("production deployment contract", () => {
); );
expect(workflow).not.toMatch(/playwright|chromium/iu); expect(workflow).not.toMatch(/playwright|chromium/iu);
}); });
it("mounts the deployment stream and offers a full reload for new releases", async () => {
const [layout, notifier, route] = await Promise.all([
repositoryFile("app/layout.tsx"),
repositoryFile("components/deployment-update-notifier.tsx"),
repositoryFile("app/api/active/route.ts"),
]);
expect(layout).toContain("<DeploymentUpdateNotifier");
expect(notifier).toContain('new EventSource("/api/active")');
expect(notifier).toContain("window.location.reload()");
expect(notifier).toContain('label: "รีโหลด"');
expect(route).toContain('channel.sendNamed("deployment", deploymentId)');
});
}); });
describe("environment template contract", () => { describe("environment template contract", () => {