From 4ef1968b04c472ebc07a53e4a5a2d3de4d2fb753 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 26 Sep 2026 23:34:42 +0700 Subject: [PATCH] feat : notify new update income --- .env.example | 3 + .gitea/workflows/ci.yml | 38 +++++++++++ app/api/active/route.ts | 26 ++++++-- app/api/deployments/status/route.test.ts | 61 +++++++++++++++++ app/api/deployments/status/route.ts | 52 +++++++++++++++ components/deployment-update-notifier.tsx | 80 +++++++++++++++++++---- k8s.md | 4 ++ k8s/base/deployment.yaml | 6 ++ lib/deployment/repository.ts | 63 ++++++++++++++++++ lib/deployment/status.test.ts | 46 +++++++++++++ lib/deployment/status.ts | 42 ++++++++++++ lib/events/redis-stream.ts | 76 +++++++++++++++++---- tests/deployment-contract.test.ts | 21 +++++- tests/security-redis.integration.test.ts | 14 ++++ 14 files changed, 499 insertions(+), 33 deletions(-) create mode 100644 app/api/deployments/status/route.test.ts create mode 100644 app/api/deployments/status/route.ts create mode 100644 lib/deployment/repository.ts create mode 100644 lib/deployment/status.test.ts create mode 100644 lib/deployment/status.ts diff --git a/.env.example b/.env.example index d7e13ca..396dc1e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b4195b3..407990e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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" diff --git a/app/api/active/route.ts b/app/api/active/route.ts index 56943d3..53f4978 100644 --- a/app/api/active/route.ts +++ b/app/api/active/route.ts @@ -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); } diff --git a/app/api/deployments/status/route.test.ts b/app/api/deployments/status/route.test.ts new file mode 100644 index 0000000..8102c4c --- /dev/null +++ b/app/api/deployments/status/route.test.ts @@ -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"); + }); +}); diff --git a/app/api/deployments/status/route.ts b/app/api/deployments/status/route.ts new file mode 100644 index 0000000..9831fe7 --- /dev/null +++ b/app/api/deployments/status/route.ts @@ -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).deploymentId) || + !isDeploymentStatus((body as Record).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); + } +} diff --git a/components/deployment-update-notifier.tsx b/components/deployment-update-notifier.tsx index 1584ab2..a284710 100644 --- a/components/deployment-update-notifier.tsx +++ b/components/deployment-update-notifier.tsx @@ -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, 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()); 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(); }; }, []); diff --git a/k8s.md b/k8s.md index 877c3aa..d38a943 100644 --- a/k8s.md +++ b/k8s.md @@ -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. diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml index c45990d..616e0fc 100644 --- a/k8s/base/deployment.yaml +++ b/k8s/base/deployment.yaml @@ -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 diff --git a/lib/deployment/repository.ts b/lib/deployment/repository.ts new file mode 100644 index 0000000..999fa07 --- /dev/null +++ b/lib/deployment/repository.ts @@ -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 = { + 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 { + 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 }; +} diff --git a/lib/deployment/status.test.ts b/lib/deployment/status.test.ts new file mode 100644 index 0000000..7a3d2ed --- /dev/null +++ b/lib/deployment/status.test.ts @@ -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(); + }); +}); diff --git a/lib/deployment/status.ts b/lib/deployment/status.ts new file mode 100644 index 0000000..3f21fca --- /dev/null +++ b/lib/deployment/status.ts @@ -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; + 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; + } +} diff --git a/lib/events/redis-stream.ts b/lib/events/redis-stream.ts index f1d3f25..180243c 100644 --- a/lib/events/redis-stream.ts +++ b/lib/events/redis-stream.ts @@ -3,8 +3,12 @@ import { createEventStream, eventStreamHeaders, type EventStreamChannel } from " import { connectRedisClient, createRedisClient, redisEventChannel } from "@/lib/redis/client"; type Subscriber = { client: ReturnType; ready: Promise }; +type StreamListener = { + stream: EventStreamChannel; + receive(value: string): void; +}; let subscriber: Subscriber | undefined; -const listeners = new Map>(); +const listeners = new Map>(); // Serialize subscribe/unsubscribe so a last-reader disconnect cannot race a join. let operations = Promise.resolve(); function ordered(task: () => Promise): Promise { @@ -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 { +interface RedisStreamOptions { + initialEvents?: + | ReadonlyArray<{ eventName: string; data: string }> + | (() => Promise>); + receive(stream: EventStreamChannel, value: string): void; +} + +async function createRedisStreamResponse( + topic: string, + signal: AbortSignal, + options: RedisStreamOptions, +): Promise { 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 { + 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 { + 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(); } diff --git a/tests/deployment-contract.test.ts b/tests/deployment-contract.test.ts index 01be8fe..7e339f7 100644 --- a/tests/deployment-contract.test.ts +++ b/tests/deployment-contract.test.ts @@ -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", diff --git a/tests/security-redis.integration.test.ts b/tests/security-redis.integration.test.ts index 6843144..77a7bc6 100644 --- a/tests/security-redis.integration.test.ts +++ b/tests/security-redis.integration.test.ts @@ -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); + }); });