feat : notify new update income

This commit is contained in:
2026-09-26 23:34:42 +07:00 Unverified
parent e99a5b76b1
commit 4ef1968b04
14 changed files with 499 additions and 33 deletions
+3
View File
@@ -50,6 +50,9 @@ NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=replace-with-a-stable-32-byte-base64-key
# Set to the immutable image revision for version-skew protection.
NEXT_DEPLOYMENT_ID=local-development
# Shared with CI to authenticate deployment lifecycle notifications.
DEPLOYMENT_WEBHOOK_SECRET=replace-with-a-strong-random-secret
# Readiness dependency timeout.
HEALTHCHECK_TIMEOUT_MS=2500
+38
View File
@@ -47,6 +47,22 @@ jobs:
- name: Check out repository
uses: actions/checkout@v4
- name: Announce incoming deployment
continue-on-error: true
env:
DEPLOYMENT_WEBHOOK_SECRET: ${{ secrets.DEPLOYMENT_WEBHOOK_SECRET }}
REVISION: ${{ gitea.sha }}
run: |
if [ -z "$DEPLOYMENT_WEBHOOK_SECRET" ]; then
echo "DEPLOYMENT_WEBHOOK_SECRET is not configured; skipping notification"
exit 0
fi
curl --fail-with-body --silent --show-error --retry 3 \
--header "Authorization: Bearer $DEPLOYMENT_WEBHOOK_SECRET" \
--header "Content-Type: application/json" \
--data "{\"deploymentId\":\"$REVISION\",\"status\":\"deploying\"}" \
"$BASE_URL/api/deployments/status"
- name: Build and push application image
run: |
docker build \
@@ -172,3 +188,25 @@ jobs:
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
deployment/buzz-sheet-discord-worker \
--timeout=10m
- name: Publish deployment result
if: always()
continue-on-error: true
env:
DEPLOYMENT_WEBHOOK_SECRET: ${{ secrets.DEPLOYMENT_WEBHOOK_SECRET }}
DEPLOYMENT_JOB_STATUS: ${{ job.status }}
REVISION: ${{ gitea.sha }}
run: |
if [ -z "$DEPLOYMENT_WEBHOOK_SECRET" ]; then
echo "DEPLOYMENT_WEBHOOK_SECRET is not configured; skipping notification"
exit 0
fi
status=failed
if [ "$DEPLOYMENT_JOB_STATUS" = "success" ]; then
status=ready
fi
curl --fail-with-body --silent --show-error --retry 3 \
--header "Authorization: Bearer $DEPLOYMENT_WEBHOOK_SECRET" \
--header "Content-Type: application/json" \
--data "{\"deploymentId\":\"$REVISION\",\"status\":\"$status\"}" \
"$BASE_URL/api/deployments/status"
+19 -7
View File
@@ -3,7 +3,11 @@ import { limitRequest, trustedClientAddress } from "@/lib/security/rate-limit";
import { connection } from "next/server";
import { normalizeDeploymentId } from "@/lib/deployment/version";
import { createEventStream, eventStreamHeaders } from "@/lib/events/sse";
import {
DEPLOYMENT_EVENT_TOPIC,
getLatestDeploymentStatus,
} from "@/lib/deployment/repository";
import { createRedisNamedEventResponse } from "@/lib/events/redis-stream";
export async function GET(request: Request) {
await connection();
@@ -11,12 +15,20 @@ export async function GET(request: Request) {
await limitRequest("stream-open", trustedClientAddress(request.headers), 60);
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(),
});
return await createRedisNamedEventResponse(
DEPLOYMENT_EVENT_TOPIC,
"deployment-status",
request.signal,
async () => {
const latestStatus = await getLatestDeploymentStatus();
return [
{ eventName: "deployment", data: deploymentId },
...(latestStatus
? [{ eventName: "deployment-status", data: JSON.stringify(latestStatus) }]
: []),
];
},
);
} catch (cause) {
return errorResponse(cause);
}
+61
View File
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const publishDeploymentStatus = vi.fn();
vi.mock("@/lib/deployment/repository", () => ({ publishDeploymentStatus }));
const { POST } = await import("./route");
const deploymentId = "a".repeat(40);
function request(body: unknown, authorization?: string) {
return new Request("https://guide.sudloh.com/api/deployments/status", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authorization ? { Authorization: authorization } : {}),
},
body: JSON.stringify(body),
});
}
describe("deployment status webhook", () => {
beforeEach(() => {
process.env.DEPLOYMENT_WEBHOOK_SECRET = "test-deployment-secret";
publishDeploymentStatus.mockReset();
publishDeploymentStatus.mockResolvedValue({
accepted: true,
event: {
deploymentId,
status: "deploying",
updatedAt: "2026-09-26T12:00:00.000Z",
},
});
});
it("rejects missing or incorrect credentials", async () => {
expect((await POST(request({ deploymentId, status: "deploying" }))).status).toBe(401);
expect((await POST(request(
{ deploymentId, status: "deploying" },
"Bearer incorrect",
))).status).toBe(401);
expect(publishDeploymentStatus).not.toHaveBeenCalled();
});
it("rejects invalid lifecycle payloads", async () => {
const response = await POST(request(
{ deploymentId: "short", status: "unknown" },
"Bearer test-deployment-secret",
));
expect(response.status).toBe(400);
expect(publishDeploymentStatus).not.toHaveBeenCalled();
});
it("publishes an authenticated lifecycle update", async () => {
const response = await POST(request(
{ deploymentId, status: "deploying" },
"Bearer test-deployment-secret",
));
expect(response.status).toBe(202);
expect(publishDeploymentStatus).toHaveBeenCalledWith(deploymentId, "deploying");
});
});
+52
View File
@@ -0,0 +1,52 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { publishDeploymentStatus } from "@/lib/deployment/repository";
import {
isDeploymentId,
isDeploymentStatus,
} from "@/lib/deployment/status";
import {
errorResponse,
HttpError,
readJson,
} from "@/lib/security/http";
function authorized(request: Request): boolean {
const secret = process.env.DEPLOYMENT_WEBHOOK_SECRET;
const authorization = request.headers.get("authorization");
if (!secret) throw new HttpError(503, "deployment-webhook-not-configured");
if (!authorization?.startsWith("Bearer ")) return false;
const supplied = authorization.slice("Bearer ".length);
const expectedDigest = createHash("sha256").update(secret).digest();
const suppliedDigest = createHash("sha256").update(supplied).digest();
return timingSafeEqual(expectedDigest, suppliedDigest);
}
export async function POST(request: Request) {
try {
if (!authorized(request)) throw new HttpError(401, "unauthorized");
const body = await readJson(request, 1_024);
if (
typeof body !== "object" ||
body === null ||
!isDeploymentId((body as Record<string, unknown>).deploymentId) ||
!isDeploymentStatus((body as Record<string, unknown>).status)
) {
throw new HttpError(400, "invalid-deployment-status");
}
const deployment = body as {
deploymentId: string;
status: "deploying" | "ready" | "failed";
};
const result = await publishDeploymentStatus(
deployment.deploymentId,
deployment.status,
);
return Response.json(result, {
status: 202,
headers: { "Cache-Control": "no-store" },
});
} catch (cause) {
return errorResponse(cause);
}
}
+66 -14
View File
@@ -7,8 +7,29 @@ import {
hasDeploymentChanged,
normalizeDeploymentId,
} from "@/lib/deployment/version";
import {
parseDeploymentStatusEvent,
type DeploymentStatus,
} from "@/lib/deployment/status";
const UPDATE_TOAST_ID = "deployment-update";
const NOTICE_DURATION_MS = 10_000;
function noticeKey(deploymentId: string, status: DeploymentStatus): string {
return `deployment-notice:${deploymentId}:${status}`;
}
function rememberNotice(seen: Set<string>, key: string): boolean {
if (seen.has(key)) return false;
try {
if (sessionStorage.getItem(key)) return false;
sessionStorage.setItem(key, "1");
} catch {
// In-memory deduplication still works when storage is unavailable.
}
seen.add(key);
return true;
}
export function DeploymentUpdateNotifier({
deploymentId,
@@ -17,23 +38,12 @@ export function DeploymentUpdateNotifier({
}) {
const currentDeployment = useRef(normalizeDeploymentId(deploymentId));
const updateAvailable = useRef(false);
const seenNotices = useRef(new Set<string>());
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;
}
const showReady = () => {
if (updateAvailable.current) return;
updateAvailable.current = true;
toast("มีอัปเดตใหม่พร้อมใช้งาน", {
id: UPDATE_TOAST_ID,
@@ -45,10 +55,52 @@ export function DeploymentUpdateNotifier({
duration: Infinity,
});
};
const receiveDeployment = (event: Event) => {
if (!(event instanceof MessageEvent) || typeof event.data !== "string") {
return;
}
const nextDeployment = normalizeDeploymentId(event.data);
if (hasDeploymentChanged(currentDeployment.current, nextDeployment)) {
showReady();
}
};
const receiveStatus = (event: Event) => {
if (!(event instanceof MessageEvent) || typeof event.data !== "string") {
return;
}
const update = parseDeploymentStatusEvent(event.data);
if (
!update ||
update.deploymentId === currentDeployment.current ||
updateAvailable.current
) {
return;
}
const key = noticeKey(update.deploymentId, update.status);
if (!rememberNotice(seenNotices.current, key)) return;
if (update.status === "ready") {
showReady();
} else if (update.status === "deploying") {
toast("กำลังอัปเดตเว็บไซต์", {
id: UPDATE_TOAST_ID,
description: "เวอร์ชันใหม่จะพร้อมใช้งานในอีกไม่กี่นาที",
duration: NOTICE_DURATION_MS,
});
} else {
toast("การอัปเดตล่าช้า", {
id: UPDATE_TOAST_ID,
description: "เวอร์ชันปัจจุบันยังใช้งานได้ตามปกติ",
duration: NOTICE_DURATION_MS,
});
}
};
source.addEventListener("deployment", receiveDeployment);
source.addEventListener("deployment-status", receiveStatus);
return () => {
source.removeEventListener("deployment", receiveDeployment);
source.removeEventListener("deployment-status", receiveStatus);
source.close();
};
}, []);
+4
View File
@@ -47,6 +47,10 @@ kubectl get secret buzz-sheet-env \
--output='go-template={{range $key, $value := .data}}{{$key}}{{"\n"}}{{end}}'
```
Add `DEPLOYMENT_WEBHOOK_SECRET` to the Gitea Actions repository secrets with
the same value stored in `.env`. CI uses it to announce deployment lifecycle
updates to the running site.
## 3. Restart the application
Environment variables sourced from a Secret are read when a pod starts.
+6
View File
@@ -107,6 +107,12 @@ spec:
configMapKeyRef:
name: buzz-sheet-config
key: NEXT_DEPLOYMENT_ID
- name: DEPLOYMENT_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
name: buzz-sheet-env
key: DEPLOYMENT_WEBHOOK_SECRET
optional: true
resources:
requests:
cpu: 500m
+63
View File
@@ -0,0 +1,63 @@
import "server-only";
import type {
DeploymentStatus,
DeploymentStatusEvent,
} from "./status";
import { parseDeploymentStatusEvent } from "./status";
import {
getRedisClient,
redisEventChannel,
redisEventPrefix,
} from "@/lib/redis/client";
export const DEPLOYMENT_EVENT_TOPIC = "deployments";
const statusKey = () => `${redisEventPrefix()}:deployments:latest`;
const STATUS_TTL_SECONDS: Record<DeploymentStatus, number> = {
deploying: 60 * 60,
ready: 10 * 60,
failed: 10 * 60,
};
const publishScript = `
if ARGV[2] ~= "deploying" then
local current = redis.call("GET", KEYS[1])
if not current then return 0 end
local ok, decoded = pcall(cjson.decode, current)
if not ok or decoded.deploymentId ~= ARGV[3] then return 0 end
end
redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[4])
redis.call("PUBLISH", KEYS[2], ARGV[1])
return 1
`;
export async function getLatestDeploymentStatus(): Promise<DeploymentStatusEvent | null> {
const redis = await getRedisClient();
const value = await redis.get(statusKey());
return value ? parseDeploymentStatusEvent(value) : null;
}
export async function publishDeploymentStatus(
deploymentId: string,
status: DeploymentStatus,
): Promise<{ accepted: boolean; event: DeploymentStatusEvent }> {
const event: DeploymentStatusEvent = {
deploymentId,
status,
updatedAt: new Date().toISOString(),
};
const payload = JSON.stringify(event);
const redis = await getRedisClient();
const accepted = await redis.eval(
publishScript,
2,
statusKey(),
redisEventChannel(DEPLOYMENT_EVENT_TOPIC),
payload,
status,
deploymentId,
STATUS_TTL_SECONDS[status],
);
return { accepted: accepted === 1, event };
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
isDeploymentId,
isDeploymentStatus,
parseDeploymentStatusEvent,
} from "./status";
const deploymentId = "a".repeat(40);
describe("deployment lifecycle status", () => {
it("accepts supported statuses and full commit identifiers", () => {
expect(isDeploymentId(deploymentId)).toBe(true);
expect(isDeploymentId("abc123")).toBe(false);
expect(isDeploymentStatus("deploying")).toBe(true);
expect(isDeploymentStatus("ready")).toBe(true);
expect(isDeploymentStatus("failed")).toBe(true);
expect(isDeploymentStatus("unknown")).toBe(false);
});
it("parses a valid lifecycle event", () => {
expect(parseDeploymentStatusEvent(JSON.stringify({
deploymentId,
status: "deploying",
updatedAt: "2026-09-26T12:00:00.000Z",
}))).toEqual({
deploymentId,
status: "deploying",
updatedAt: "2026-09-26T12:00:00.000Z",
});
});
it("rejects malformed or incomplete events", () => {
expect(parseDeploymentStatusEvent("not-json")).toBeNull();
expect(parseDeploymentStatusEvent(JSON.stringify({
deploymentId,
status: "unknown",
updatedAt: "2026-09-26T12:00:00.000Z",
}))).toBeNull();
expect(parseDeploymentStatusEvent(JSON.stringify({
deploymentId,
status: "ready",
updatedAt: "not-a-date",
}))).toBeNull();
});
});
+42
View File
@@ -0,0 +1,42 @@
export const DEPLOYMENT_STATUSES = ["deploying", "ready", "failed"] as const;
export type DeploymentStatus = (typeof DEPLOYMENT_STATUSES)[number];
export interface DeploymentStatusEvent {
deploymentId: string;
status: DeploymentStatus;
updatedAt: string;
}
const DEPLOYMENT_ID_PATTERN = /^[a-f0-9]{40}$/u;
export function isDeploymentId(value: unknown): value is string {
return typeof value === "string" && DEPLOYMENT_ID_PATTERN.test(value);
}
export function isDeploymentStatus(value: unknown): value is DeploymentStatus {
return DEPLOYMENT_STATUSES.some((status) => status === value);
}
export function parseDeploymentStatusEvent(
value: string,
): DeploymentStatusEvent | null {
try {
const parsed = JSON.parse(value) as Record<string, unknown>;
if (
!isDeploymentId(parsed.deploymentId) ||
!isDeploymentStatus(parsed.status) ||
typeof parsed.updatedAt !== "string" ||
!Number.isFinite(Date.parse(parsed.updatedAt))
) {
return null;
}
return {
deploymentId: parsed.deploymentId,
status: parsed.status,
updatedAt: parsed.updatedAt,
};
} catch {
return null;
}
}
+65 -11
View File
@@ -3,8 +3,12 @@ import { createEventStream, eventStreamHeaders, type EventStreamChannel } from "
import { connectRedisClient, createRedisClient, redisEventChannel } from "@/lib/redis/client";
type Subscriber = { client: ReturnType<typeof createRedisClient>; ready: Promise<void> };
type StreamListener = {
stream: EventStreamChannel;
receive(value: string): void;
};
let subscriber: Subscriber | undefined;
const listeners = new Map<string, Set<EventStreamChannel>>();
const listeners = new Map<string, Set<StreamListener>>();
// Serialize subscribe/unsubscribe so a last-reader disconnect cannot race a join.
let operations = Promise.resolve();
function ordered<T>(task: () => Promise<T>): Promise<T> {
@@ -19,15 +23,15 @@ function getSubscriber(): Subscriber {
const state: Subscriber = { client, ready: Promise.resolve() };
subscriber = state;
client.on("message", (channel, value) => {
const event = parseInvalidationEvent(value);
if (!event || redisEventChannel(expectedTopic(event)) !== channel) return;
for (const stream of listeners.get(channel) ?? []) stream.send(event);
for (const listener of listeners.get(channel) ?? []) listener.receive(value);
});
client.on("close", () => {
if (subscriber !== state) return;
subscriber = undefined;
// Force EventSource reconnect/refetch after a gap in the Pub/Sub delivery.
for (const streams of listeners.values()) for (const stream of [...streams]) stream.close();
for (const streams of listeners.values()) {
for (const listener of [...streams]) listener.stream.close();
}
listeners.clear();
client.disconnect();
});
@@ -39,7 +43,18 @@ function getSubscriber(): Subscriber {
return state;
}
export async function createRedisEventResponse(topic: string, signal: AbortSignal): Promise<Response> {
interface RedisStreamOptions {
initialEvents?:
| ReadonlyArray<{ eventName: string; data: string }>
| (() => Promise<ReadonlyArray<{ eventName: string; data: string }>>);
receive(stream: EventStreamChannel, value: string): void;
}
async function createRedisStreamResponse(
topic: string,
signal: AbortSignal,
options: RedisStreamOptions,
): Promise<Response> {
const channel = redisEventChannel(topic);
const state = getSubscriber();
await state.ready;
@@ -50,9 +65,9 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
await state.client.subscribe(channel);
listeners.set(channel, new Set());
}
let eventStream: EventStreamChannel | undefined;
let listener: StreamListener | undefined;
const release = () => {
if (eventStream) listeners.get(channel)?.delete(eventStream);
if (listener) listeners.get(channel)?.delete(listener);
void ordered(async () => {
if (subscriber === state && listeners.get(channel)?.size === 0) {
listeners.delete(channel);
@@ -62,10 +77,21 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
};
try {
signal.throwIfAborted();
eventStream = createEventStream({ signal, onClose: release });
listeners.get(channel)?.add(eventStream);
const eventStream = createEventStream({ signal, onClose: release });
listener = {
stream: eventStream,
receive: (value) => options.receive(eventStream, value),
};
listeners.get(channel)?.add(listener);
const initialEvents = typeof options.initialEvents === "function"
? await options.initialEvents()
: options.initialEvents ?? [];
for (const event of initialEvents) {
eventStream.sendNamed(event.eventName, event.data);
}
return eventStream;
} catch (cause) {
listener?.stream.close();
release();
throw cause;
}
@@ -73,10 +99,38 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
return new Response(stream.stream, { headers: eventStreamHeaders() });
}
export async function createRedisEventResponse(
topic: string,
signal: AbortSignal,
): Promise<Response> {
return createRedisStreamResponse(topic, signal, {
receive(stream, value) {
const event = parseInvalidationEvent(value);
if (event && expectedTopic(event) === topic) stream.send(event);
},
});
}
export async function createRedisNamedEventResponse(
topic: string,
eventName: string,
signal: AbortSignal,
initialEvents: RedisStreamOptions["initialEvents"] = [],
): Promise<Response> {
return createRedisStreamResponse(topic, signal, {
initialEvents,
receive(stream, value) {
stream.sendNamed(eventName, value);
},
});
}
export function closeRedisEventStreams() {
const state = subscriber;
subscriber = undefined;
for (const streams of listeners.values()) for (const stream of [...streams]) stream.close();
for (const streams of listeners.values()) {
for (const listener of [...streams]) listener.stream.close();
}
listeners.clear();
state?.client.disconnect();
}
+20 -1
View File
@@ -156,7 +156,25 @@ describe("production deployment contract", () => {
expect(notifier).toContain('new EventSource("/api/active")');
expect(notifier).toContain("window.location.reload()");
expect(notifier).toContain('label: "รีโหลด"');
expect(route).toContain('channel.sendNamed("deployment", deploymentId)');
expect(notifier).toContain('source.addEventListener("deployment-status"');
expect(route).toContain('"deployment-status"');
});
it("announces deployment lifecycle results without blocking releases", async () => {
const [workflow, deployment] = await Promise.all([
repositoryFile(".gitea/workflows/ci.yml"),
repositoryFile("k8s/base/deployment.yaml"),
]);
expect(workflow).toContain("Announce incoming deployment");
expect(workflow).toContain("Publish deployment result");
expect(workflow).toContain("if: always()");
expect(workflow).toContain("continue-on-error: true");
expect(workflow).toContain('status\\":\\"deploying');
expect(workflow).toContain("DEPLOYMENT_JOB_STATUS");
expect(workflow).toContain("/api/deployments/status");
expect(deployment).toContain("DEPLOYMENT_WEBHOOK_SECRET");
expect(deployment).toContain("optional: true");
});
});
@@ -174,6 +192,7 @@ describe("environment template contract", () => {
"S3_SECRET_ACCESS_KEY",
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
"NEXT_DEPLOYMENT_ID",
"DEPLOYMENT_WEBHOOK_SECRET",
"DISCORD_BOT_TOKEN",
"DISCORD_CHANNEL_ID",
"DISCORD_LOG_CHANNEL_ID",
+14
View File
@@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
const { closeRedisEventStreams, createRedisEventResponse } = await import("@/lib/events/redis-stream");
const { getLatestDeploymentStatus, publishDeploymentStatus } = await import("@/lib/deployment/repository");
const { updateVisitorPresence } = await import("@/lib/presence");
const { closeRedisClient, redisEventChannel } = await import("@/lib/redis/client");
const { consumeRateLimit } = await import("@/lib/security/rate-limit");
@@ -102,4 +103,17 @@ describeWithRedis("shared Redis security paths", () => {
second.abort();
await Promise.all([readerA.cancel(), readerB.cancel()]);
});
it("does not let stale deployment results replace a newer announcement", async () => {
const firstId = "a".repeat(40);
const secondId = "b".repeat(40);
expect((await publishDeploymentStatus(firstId, "deploying")).accepted).toBe(true);
expect((await publishDeploymentStatus(secondId, "deploying")).accepted).toBe(true);
expect((await publishDeploymentStatus(firstId, "ready")).accepted).toBe(false);
expect(await getLatestDeploymentStatus()).toMatchObject({
deploymentId: secondId,
status: "deploying",
});
expect((await publishDeploymentStatus(secondId, "failed")).accepted).toBe(true);
});
});