This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit bf2a36868b279781824338dbf4d8d5ac72a06181
Author: Logic <[email protected]>
AuthorDate: Fri May 29 02:14:08 2026 +0800

    feat(web-next): add release runtime readiness gates
---
 .github/workflows/frontend-build-test.yml          |   7 +-
 .../docker-compose.yaml                            |   5 +-
 .../nginx/default.conf                             |  20 +++
 web-next/Dockerfile                                |  20 ++-
 web-next/app/api/[...path]/route.ts                |  19 +++
 web-next/next.config.mjs                           |  19 +--
 web-next/package.json                              |   6 +-
 web-next/scripts/release-budget.mjs                | 100 +++++++++++
 web-next/scripts/release-budget.test.ts            |  60 +++++++
 web-next/scripts/release-checklist.mjs             | 111 ++++++++++++
 web-next/scripts/release-checklist.test.ts         |  64 +++++++
 web-next/scripts/release-compose.mjs               |  85 ++++++++++
 web-next/scripts/release-compose.test.ts           |  59 +++++++
 .../scripts/release-readiness-contract.test.ts     | 188 +++++++++++++++++++++
 web-next/scripts/session-security-contract.test.ts |  68 ++++++++
 15 files changed, 809 insertions(+), 22 deletions(-)

diff --git a/.github/workflows/frontend-build-test.yml 
b/.github/workflows/frontend-build-test.yml
index 8daadcec93..55b57c95de 100644
--- a/.github/workflows/frontend-build-test.yml
+++ b/.github/workflows/frontend-build-test.yml
@@ -22,14 +22,14 @@ name: Frontend CI
 
 on:
   push:
-    branches: [ master, dev, action* ]
+    branches: [ master, dev, 'action*', 'codex/**', 'feature/**', 'release/**' 
]
     paths:
       - 'web-app/**'
       - 'web-next/**'
       - 'web-app/src/assets/i18n/**'
       - '.github/workflows/frontend-build-test.yml'
   pull_request:
-    branches: [ master, dev ]
+    branches: [ master, dev, 'codex/**', 'feature/**', 'release/**' ]
     paths:
       - 'web-app/**'
       - 'web-next/**'
@@ -107,3 +107,6 @@ jobs:
     - name: verify:full (blocking)
       working-directory: web-next
       run: npm run verify:full
+    - name: parity:smoke:baseline (blocking)
+      working-directory: web-next
+      run: npm run parity:smoke:baseline
diff --git 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml
 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml
index 5882f9c52e..77adc90084 100644
--- 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml
+++ 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml
@@ -63,7 +63,7 @@ services:
       - hertzbeat
 
   hertzbeat:
-    image: apache/hertzbeat:1.8.0
+    image: 
"${HERTZBEAT_SERVER_IMAGE:-apache/hertzbeat}:${HERTZBEAT_RELEASE_VERSION:-1.8.0}"
     container_name: compose-hertzbeat
     hostname: hertzbeat
     restart: always
@@ -88,9 +88,12 @@ services:
       - hertzbeat
 
   web-next:
+    image: 
"${HERTZBEAT_WEB_NEXT_IMAGE:-apache/hertzbeat-web-next}:${HERTZBEAT_RELEASE_VERSION:-1.8.0}"
     build:
       context: ../../..
       dockerfile: web-next/Dockerfile
+      args:
+        HERTZBEAT_RELEASE_VERSION: ${HERTZBEAT_RELEASE_VERSION:-1.8.0}
     container_name: compose-hertzbeat-web-next
     hostname: web-next
     restart: always
diff --git 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf
 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf
index cb4f3ed834..a506ba6773 100644
--- 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf
+++ 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf
@@ -12,6 +12,14 @@ server {
     proxy_pass http://web-next:4200;
   }
 
+  location ^~ /overview {
+    proxy_pass http://web-next:4200;
+  }
+
+  location ^~ /entities {
+    proxy_pass http://web-next:4200;
+  }
+
   location ^~ /trace/ {
     proxy_pass http://web-next:4200;
   }
@@ -24,6 +32,18 @@ server {
     proxy_pass http://web-next:4200;
   }
 
+  location ^~ /alert {
+    proxy_pass http://web-next:4200;
+  }
+
+  location ^~ /topology {
+    proxy_pass http://web-next:4200;
+  }
+
+  location ^~ /setting {
+    proxy_pass http://web-next:4200;
+  }
+
   location ^~ /ingestion/otlp {
     proxy_pass http://web-next:4200;
   }
diff --git a/web-next/Dockerfile b/web-next/Dockerfile
index af87301a8f..997b258830 100644
--- a/web-next/Dockerfile
+++ b/web-next/Dockerfile
@@ -1,21 +1,31 @@
-FROM node:20-alpine AS deps
+FROM node:22-alpine AS deps
 WORKDIR /app
 COPY web-next/package.json web-next/package-lock.json ./
 RUN npm ci
 
-FROM node:20-alpine AS builder
+FROM node:22-alpine AS builder
 WORKDIR /app
 ENV NEXT_TELEMETRY_DISABLED=1
+ENV NEXT_OUTPUT_FILE_TRACING_ROOT=/app
 COPY --from=deps /app/node_modules ./node_modules
 COPY web-next ./
 RUN npm run build
 
-FROM node:20-alpine AS runner
+FROM node:22-alpine AS runner
 WORKDIR /app
+ARG HERTZBEAT_RELEASE_VERSION=dev
 ENV NODE_ENV=production
 ENV NEXT_TELEMETRY_DISABLED=1
 ENV PORT=4200
 ENV HOSTNAME=0.0.0.0
-COPY --from=builder /app ./
+LABEL org.opencontainers.image.title="Apache HertzBeat Web Next"
+LABEL org.opencontainers.image.version="${HERTZBEAT_RELEASE_VERSION}"
+RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+COPY --from=builder /app/public ./public
+RUN chown -R nextjs:nodejs /app
+USER nextjs
 EXPOSE 4200
-CMD ["npx", "next", "start", "-p", "4200", "-H", "0.0.0.0"]
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD 
wget -q -O - "http://127.0.0.1:${PORT}/overview"; > /dev/null || exit 1
+CMD ["node", "server.js"]
diff --git a/web-next/app/api/[...path]/route.ts 
b/web-next/app/api/[...path]/route.ts
new file mode 100644
index 0000000000..50c09daa1e
--- /dev/null
+++ b/web-next/app/api/[...path]/route.ts
@@ -0,0 +1,19 @@
+import { NextRequest } from 'next/server';
+import { proxyBackendApiRequest } from '@/lib/session-bff';
+
+export const dynamic = 'force-dynamic';
+
+type RouteContext = {
+  params: Promise<{ path?: string[] }>;
+};
+
+async function proxy(request: NextRequest, context: RouteContext) {
+  const params = await context.params;
+  return proxyBackendApiRequest(request, `/${params.path?.join('/') || ''}`);
+}
+
+export const GET = proxy;
+export const POST = proxy;
+export const PUT = proxy;
+export const PATCH = proxy;
+export const DELETE = proxy;
diff --git a/web-next/next.config.mjs b/web-next/next.config.mjs
index 92a0fd4d93..ed10597113 100644
--- a/web-next/next.config.mjs
+++ b/web-next/next.config.mjs
@@ -1,25 +1,18 @@
 import path from 'node:path';
 
+const outputFileTracingRoot = process.env.NEXT_OUTPUT_FILE_TRACING_ROOT
+  ? path.resolve(process.env.NEXT_OUTPUT_FILE_TRACING_ROOT)
+  : path.join(process.cwd(), '..');
+
 /** @type {import('next').NextConfig} */
 const nextConfig = {
   reactStrictMode: true,
   poweredByHeader: false,
   distDir: process.env.NEXT_DIST_DIR || '.next',
-  outputFileTracingRoot: path.join(process.cwd(), '..'),
+  output: 'standalone',
+  outputFileTracingRoot,
   eslint: {
     ignoreDuringBuilds: false
-  },
-  async rewrites() {
-    const backendOrigin = process.env.BACKEND_ORIGIN;
-    if (!backendOrigin) {
-      return [];
-    }
-    return [
-      {
-        source: '/api/:path*',
-        destination: `${backendOrigin}/api/:path*`
-      }
-    ];
   }
 };
 
diff --git a/web-next/package.json b/web-next/package.json
index c8f2969d5e..94a7c4a4ae 100644
--- a/web-next/package.json
+++ b/web-next/package.json
@@ -10,9 +10,13 @@
     "test": "vitest run",
     "parity:runtime": "node ./scripts/parity/runtime.mjs",
     "parity:smoke": "node ./scripts/parity/harness.mjs",
+    "parity:smoke:baseline": "node ./scripts/parity/harness.mjs --milestone 1",
     "route:matrix": "node ./scripts/route-matrix.mjs",
+    "release:budget": "node ./scripts/release-budget.mjs",
+    "release:compose": "node ./scripts/release-compose.mjs",
+    "release:checklist": "node ./scripts/release-checklist.mjs",
     "verify": "npm run lint && npm run test && npm run i18n:report && npm run 
route:matrix",
-    "verify:full": "npm run build && npm run verify",
+    "verify:full": "npm run build && npm run release:budget && npm run 
release:compose && npm run release:checklist && npm run verify",
     "i18n:report": "node ./scripts/i18n-report.mjs"
   },
   "dependencies": {
diff --git a/web-next/scripts/release-budget.mjs 
b/web-next/scripts/release-budget.mjs
new file mode 100644
index 0000000000..8107eea86a
--- /dev/null
+++ b/web-next/scripts/release-budget.mjs
@@ -0,0 +1,100 @@
+import { existsSync, readdirSync, statSync } from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+
+export const DEFAULT_RELEASE_BUDGET_BYTES = 8 * 1024 * 1024;
+
+export function parseBudgetBytes(value = 
process.env.HERTZBEAT_WEB_NEXT_BUNDLE_BUDGET_BYTES) {
+  if (value === undefined || value === null || String(value).trim() === '') {
+    return DEFAULT_RELEASE_BUDGET_BYTES;
+  }
+
+  const parsed = Number.parseInt(String(value), 10);
+  if (!Number.isFinite(parsed) || parsed <= 0) {
+    throw new Error(`Invalid HERTZBEAT_WEB_NEXT_BUNDLE_BUDGET_BYTES value: 
${value}`);
+  }
+  return parsed;
+}
+
+export function formatBudgetBytes(bytes) {
+  if (bytes >= 1024 * 1024) {
+    return `${(bytes / 1024 / 1024).toFixed(2)} MiB`;
+  }
+  if (bytes >= 1024) {
+    return `${(bytes / 1024).toFixed(2)} KiB`;
+  }
+  return `${bytes} B`;
+}
+
+function collectFiles(rootDir) {
+  if (!existsSync(rootDir)) {
+    return [];
+  }
+
+  return readdirSync(rootDir, { withFileTypes: true }).flatMap(entry => {
+    const entryPath = path.join(rootDir, entry.name);
+    if (entry.isDirectory()) {
+      return collectFiles(entryPath);
+    }
+    if (entry.isFile()) {
+      return [entryPath];
+    }
+    return [];
+  });
+}
+
+export function collectReleaseJsAssets(distDir = process.env.NEXT_DIST_DIR || 
'.next') {
+  const staticDir = path.resolve(process.cwd(), distDir, 'static');
+
+  return collectFiles(staticDir)
+    .filter(filePath => filePath.endsWith('.js'))
+    .map(filePath => ({
+      path: path.relative(path.resolve(process.cwd(), distDir), 
filePath).replaceAll(path.sep, '/'),
+      bytes: statSync(filePath).size
+    }))
+    .sort((left, right) => left.path.localeCompare(right.path));
+}
+
+export function evaluateReleaseBudget(entries, maxBytes = 
DEFAULT_RELEASE_BUDGET_BYTES) {
+  const normalizedEntries = entries
+    .map(entry => ({
+      path: String(entry.path || ''),
+      bytes: Number(entry.bytes || 0)
+    }))
+    .filter(entry => entry.path && Number.isFinite(entry.bytes) && entry.bytes 
>= 0);
+  const totalBytes = normalizedEntries.reduce((total, entry) => total + 
entry.bytes, 0);
+  const largestEntries = [...normalizedEntries].sort((left, right) => 
right.bytes - left.bytes).slice(0, 5);
+
+  return {
+    totalBytes,
+    maxBytes,
+    passed: totalBytes <= maxBytes,
+    largestEntries
+  };
+}
+
+export function assertReleaseBudget({ distDir = process.env.NEXT_DIST_DIR || 
'.next', maxBytes = parseBudgetBytes() } = {}) {
+  const entries = collectReleaseJsAssets(distDir);
+  if (entries.length === 0) {
+    throw new Error(`No Next.js release JavaScript assets found under 
${distDir}/static. Run npm run build first.`);
+  }
+
+  const result = evaluateReleaseBudget(entries, maxBytes);
+  if (!result.passed) {
+    const largest = result.largestEntries
+      .map(entry => `${entry.path}=${formatBudgetBytes(entry.bytes)}`)
+      .join(', ');
+    throw new Error(
+      `web-next release JavaScript assets exceed budget: 
${formatBudgetBytes(result.totalBytes)} > `
+        + `${formatBudgetBytes(result.maxBytes)}. Largest chunks: ${largest}`,
+    );
+  }
+  return result;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const result = assertReleaseBudget();
+  console.log(
+    `web-next release JavaScript assets: 
${formatBudgetBytes(result.totalBytes)} / 
${formatBudgetBytes(result.maxBytes)}`,
+  );
+}
diff --git a/web-next/scripts/release-budget.test.ts 
b/web-next/scripts/release-budget.test.ts
new file mode 100644
index 0000000000..5b24cca51a
--- /dev/null
+++ b/web-next/scripts/release-budget.test.ts
@@ -0,0 +1,60 @@
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+
+import { describe, expect, it } from 'vitest';
+
+import {
+  collectReleaseJsAssets,
+  evaluateReleaseBudget,
+  formatBudgetBytes,
+  parseBudgetBytes
+} from './release-budget.mjs';
+
+describe('release budget gate', () => {
+  it('collects built Next.js static JavaScript assets without counting non-js 
files', () => {
+    const currentWorkingDirectory = process.cwd();
+    const tempRoot = mkdtempSync(path.join(tmpdir(), 
'hertzbeat-release-budget-'));
+    try {
+      const chunksDir = path.join(tempRoot, '.next/static/chunks/app');
+      mkdirSync(chunksDir, { recursive: true });
+      writeFileSync(path.join(chunksDir, 'overview.js'), 'x'.repeat(13));
+      writeFileSync(path.join(chunksDir, 'overview.css'), 'x'.repeat(99));
+
+      process.chdir(tempRoot);
+
+      expect(collectReleaseJsAssets()).toEqual([
+        {
+          path: 'static/chunks/app/overview.js',
+          bytes: 13
+        }
+      ]);
+    } finally {
+      process.chdir(currentWorkingDirectory);
+      rmSync(tempRoot, { recursive: true, force: true });
+    }
+  });
+
+  it('evaluates total release chunk size against a configurable budget', () => 
{
+    const result = evaluateReleaseBudget(
+      [
+        { path: 'static/chunks/a.js', bytes: 6 },
+        { path: 'static/chunks/b.js', bytes: 5 }
+      ],
+      10,
+    );
+
+    expect(result).toMatchObject({
+      totalBytes: 11,
+      maxBytes: 10,
+      passed: false
+    });
+    expect(result.largestEntries.map(entry => 
entry.path)).toEqual(['static/chunks/a.js', 'static/chunks/b.js']);
+  });
+
+  it('parses and formats release budget values for CI diagnostics', () => {
+    expect(parseBudgetBytes('2048')).toBe(2048);
+    expect(formatBudgetBytes(2048)).toBe('2.00 KiB');
+    expect(() => parseBudgetBytes('0')).toThrow('Invalid 
HERTZBEAT_WEB_NEXT_BUNDLE_BUDGET_BYTES');
+  });
+});
diff --git a/web-next/scripts/release-checklist.mjs 
b/web-next/scripts/release-checklist.mjs
new file mode 100644
index 0000000000..ed1c6a0e2d
--- /dev/null
+++ b/web-next/scripts/release-checklist.mjs
@@ -0,0 +1,111 @@
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const webNextRoot = path.resolve(scriptDir, '..');
+const repoRoot = path.resolve(webNextRoot, '..');
+
+export const REQUIRED_WEB_NEXT_INGRESS_PREFIXES = [
+  '/_next/',
+  '/overview',
+  '/entities',
+  '/alert',
+  '/topology',
+  '/setting'
+];
+
+export const RELEASE_CHECKLIST_ITEMS = [
+  {
+    key: 'ci-blocking-gates',
+    label: 'Frontend CI keeps build, budget, compose, checklist, tests, and 
smoke blocking',
+    verify: files =>
+      files.packageJson.scripts?.['verify:full'] ===
+        'npm run build && npm run release:budget && npm run release:compose && 
npm run release:checklist && npm run verify' &&
+      files.workflow.includes('npm run verify:full') &&
+      files.workflow.includes('npm run parity:smoke:baseline')
+  },
+  {
+    key: 'standalone-release-image',
+    label: 'web-next release image is standalone, non-root, healthchecked, and 
version labeled',
+    verify: files =>
+      files.nextConfig.includes("output: 'standalone'") &&
+      files.dockerfile.includes('FROM node:22-alpine') &&
+      files.dockerfile.includes('USER nextjs') &&
+      files.dockerfile.includes('HEALTHCHECK') &&
+      
files.dockerfile.includes('org.opencontainers.image.version="${HERTZBEAT_RELEASE_VERSION}"')
+  },
+  {
+    key: 'compose-version-convergence',
+    label: 'Compose release stack shares one overrideable server/web-next 
release version',
+    verify: files =>
+      files.compose.includes('${HERTZBEAT_RELEASE_VERSION:-1.8.0}') &&
+      files.compose.includes('${HERTZBEAT_SERVER_IMAGE:-apache/hertzbeat}') &&
+      
files.compose.includes('${HERTZBEAT_WEB_NEXT_IMAGE:-apache/hertzbeat-web-next}')
+  },
+  {
+    key: 'promotion-rollback-config',
+    label: 'Promotion and rollback tags are validated through docker compose 
config',
+    verify: files =>
+      files.packageJson.scripts?.['release:compose'] === 'node 
./scripts/release-compose.mjs' &&
+      files.releaseCompose.includes('verifyReleaseComposeConfig') &&
+      files.releaseCompose.includes('HERTZBEAT_ROLLBACK_VERSION')
+  },
+  {
+    key: 'bundle-budget',
+    label: 'Production Next build has a JavaScript bundle budget gate',
+    verify: files =>
+      files.packageJson.scripts?.['release:budget'] === 'node 
./scripts/release-budget.mjs' &&
+      files.releaseBudget.includes('DEFAULT_RELEASE_BUDGET_BYTES') &&
+      files.releaseBudget.includes('evaluateReleaseBudget')
+  },
+  {
+    key: 'release-ingress-route-ownership',
+    label: 'Gateway routes operator route families to web-next and 
APIs/fallback to Spring',
+    verify: files =>
+      REQUIRED_WEB_NEXT_INGRESS_PREFIXES.every(prefix => 
files.gateway.includes(`location ^~ ${prefix}`)) &&
+      files.gateway.includes('proxy_pass http://web-next:4200;') &&
+      files.gateway.includes('location ^~ /api/') &&
+      files.gateway.includes('location /') &&
+      files.gateway.includes('proxy_pass http://hertzbeat:1157;')
+  }
+];
+
+export function readReleaseChecklistFiles(rootDir = repoRoot) {
+  const readRepoFile = relativePath => readFileSync(path.join(rootDir, 
relativePath), 'utf8');
+  const packageText = readRepoFile('web-next/package.json');
+
+  return {
+    packageJson: JSON.parse(packageText),
+    workflow: readRepoFile('.github/workflows/frontend-build-test.yml'),
+    dockerfile: readRepoFile('web-next/Dockerfile'),
+    nextConfig: readRepoFile('web-next/next.config.mjs'),
+    compose: 
readRepoFile('script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml'),
+    gateway: 
readRepoFile('script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf'),
+    releaseBudget: readRepoFile('web-next/scripts/release-budget.mjs'),
+    releaseCompose: readRepoFile('web-next/scripts/release-compose.mjs')
+  };
+}
+
+export function evaluateReleaseChecklist(files = readReleaseChecklistFiles()) {
+  return RELEASE_CHECKLIST_ITEMS.map(item => ({
+    key: item.key,
+    label: item.label,
+    passed: Boolean(item.verify(files))
+  }));
+}
+
+export function verifyReleaseChecklist(files = readReleaseChecklistFiles()) {
+  const results = evaluateReleaseChecklist(files);
+  const failures = results.filter(result => !result.passed);
+  if (failures.length > 0) {
+    throw new Error(`Release checklist failed: ${failures.map(result => 
result.key).join(', ')}`);
+  }
+  return results;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const results = verifyReleaseChecklist();
+  console.log(`release checklist ok: ${results.map(result => 
result.key).join(', ')}`);
+}
diff --git a/web-next/scripts/release-checklist.test.ts 
b/web-next/scripts/release-checklist.test.ts
new file mode 100644
index 0000000000..19264688ec
--- /dev/null
+++ b/web-next/scripts/release-checklist.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  REQUIRED_WEB_NEXT_INGRESS_PREFIXES,
+  evaluateReleaseChecklist,
+  verifyReleaseChecklist
+} from './release-checklist.mjs';
+
+function releaseFiles(overrides = {}) {
+  const gatewayRoutes = REQUIRED_WEB_NEXT_INGRESS_PREFIXES
+    .map(prefix => `location ^~ ${prefix} {\n  proxy_pass 
http://web-next:4200;\n}`)
+    .join('\n');
+
+  return {
+    packageJson: {
+      scripts: {
+        'verify:full':
+          'npm run build && npm run release:budget && npm run release:compose 
&& npm run release:checklist && npm run verify',
+        'release:budget': 'node ./scripts/release-budget.mjs',
+        'release:compose': 'node ./scripts/release-compose.mjs'
+      }
+    },
+    workflow: 'run: npm run verify:full\nrun: npm run parity:smoke:baseline',
+    dockerfile:
+      'FROM node:22-alpine\nUSER nextjs\nHEALTHCHECK CMD test\nLABEL 
org.opencontainers.image.version="${HERTZBEAT_RELEASE_VERSION}"',
+    nextConfig: "output: 'standalone'",
+    compose:
+      
'${HERTZBEAT_RELEASE_VERSION:-1.8.0}\n${HERTZBEAT_SERVER_IMAGE:-apache/hertzbeat}\n${HERTZBEAT_WEB_NEXT_IMAGE:-apache/hertzbeat-web-next}',
+    gateway: `${gatewayRoutes}\nlocation ^~ /api/ {\n proxy_pass 
http://hertzbeat:1157;\n}\nlocation / {\n proxy_pass http://hertzbeat:1157;\n}`,
+    releaseBudget: 'DEFAULT_RELEASE_BUDGET_BYTES\nevaluateReleaseBudget',
+    releaseCompose: 'verifyReleaseComposeConfig\nHERTZBEAT_ROLLBACK_VERSION',
+    ...overrides
+  };
+}
+
+describe('release checklist gate', () => {
+  it('passes only when every release-readiness item is present', () => {
+    const results = verifyReleaseChecklist(releaseFiles());
+
+    expect(results.every(result => result.passed)).toBe(true);
+    expect(results.map(result => result.key)).toEqual([
+      'ci-blocking-gates',
+      'standalone-release-image',
+      'compose-version-convergence',
+      'promotion-rollback-config',
+      'bundle-budget',
+      'release-ingress-route-ownership'
+    ]);
+  });
+
+  it('fails with the missing checklist key when release ingress drops a 
web-next route family', () => {
+    const files = releaseFiles({
+      gateway:
+        'location ^~ /_next/ { proxy_pass http://web-next:4200; }\n'
+        + 'location ^~ /api/ { proxy_pass http://hertzbeat:1157; }\n'
+        + 'location / { proxy_pass http://hertzbeat:1157; }'
+    });
+
+    expect(evaluateReleaseChecklist(files).find(result => result.key === 
'release-ingress-route-ownership')?.passed).toBe(
+      false,
+    );
+    expect(() => 
verifyReleaseChecklist(files)).toThrow('release-ingress-route-ownership');
+  });
+});
diff --git a/web-next/scripts/release-compose.mjs 
b/web-next/scripts/release-compose.mjs
new file mode 100644
index 0000000000..910f57f9aa
--- /dev/null
+++ b/web-next/scripts/release-compose.mjs
@@ -0,0 +1,85 @@
+import { spawnSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptDir, '../..');
+export const NEXT_OBSERVABILITY_COMPOSE =
+  
'script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml';
+
+export function readRootReleaseVersion(rootPom = 
readFileSync(path.join(repoRoot, 'pom.xml'), 'utf8')) {
+  const version = 
rootPom.match(/<hzb\.version>\s*([^<]+?)\s*<\/hzb\.version>/)?.[1]?.trim();
+  if (!version) {
+    throw new Error('Unable to resolve <hzb.version> from root pom.xml');
+  }
+  return version;
+}
+
+export function rollbackProbeVersion(releaseVersion) {
+  return process.env.HERTZBEAT_ROLLBACK_VERSION || 
`${releaseVersion}-rollback-check`;
+}
+
+export function composeConfigEnv(releaseVersion, extraEnv = process.env) {
+  return {
+    ...extraEnv,
+    HERTZBEAT_RELEASE_VERSION: releaseVersion,
+    HERTZBEAT_SERVER_IMAGE: extraEnv.HERTZBEAT_SERVER_IMAGE || 
'apache/hertzbeat',
+    HERTZBEAT_WEB_NEXT_IMAGE: extraEnv.HERTZBEAT_WEB_NEXT_IMAGE || 
'apache/hertzbeat-web-next'
+  };
+}
+
+export function assertResolvedReleaseImages(configText, releaseVersion) {
+  const expectedServerImage = `image: apache/hertzbeat:${releaseVersion}`;
+  const expectedWebNextImage = `image: 
apache/hertzbeat-web-next:${releaseVersion}`;
+  const expectedBuildArg = `HERTZBEAT_RELEASE_VERSION: ${releaseVersion}`;
+
+  for (const expected of [expectedServerImage, expectedWebNextImage, 
expectedBuildArg]) {
+    if (!configText.includes(expected)) {
+      throw new Error(`docker compose config did not resolve ${expected}`);
+    }
+  }
+}
+
+export function runDockerComposeConfig(releaseVersion) {
+  const composePath = path.join(repoRoot, NEXT_OBSERVABILITY_COMPOSE);
+  const command = ['compose', '-f', composePath, 'config'];
+  const result = spawnSync('docker', command, {
+    cwd: repoRoot,
+    env: composeConfigEnv(releaseVersion),
+    encoding: 'utf8'
+  });
+
+  if (result.error || result.status !== 0) {
+    throw new Error(
+      `docker compose config failed for release ${releaseVersion}: ${
+        result.error?.message || result.stderr || `exit ${result.status}`
+      }`,
+    );
+  }
+
+  return result.stdout;
+}
+
+export function verifyReleaseComposeConfig({
+  releaseVersion = process.env.HERTZBEAT_RELEASE_VERSION || 
readRootReleaseVersion(),
+  rollbackVersion = rollbackProbeVersion(releaseVersion),
+  configRunner = runDockerComposeConfig
+} = {}) {
+  const promotionConfig = configRunner(releaseVersion);
+  assertResolvedReleaseImages(promotionConfig, releaseVersion);
+
+  const rollbackConfig = configRunner(rollbackVersion);
+  assertResolvedReleaseImages(rollbackConfig, rollbackVersion);
+
+  return {
+    releaseVersion,
+    rollbackVersion
+  };
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const result = verifyReleaseComposeConfig();
+  console.log(`docker compose promotion/rollback config ok: 
${result.releaseVersion} -> ${result.rollbackVersion}`);
+}
diff --git a/web-next/scripts/release-compose.test.ts 
b/web-next/scripts/release-compose.test.ts
new file mode 100644
index 0000000000..3ce1bee5ec
--- /dev/null
+++ b/web-next/scripts/release-compose.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  assertResolvedReleaseImages,
+  composeConfigEnv,
+  readRootReleaseVersion,
+  rollbackProbeVersion,
+  verifyReleaseComposeConfig
+} from './release-compose.mjs';
+
+describe('release compose gate', () => {
+  it('resolves the root release version from hzb.version', () => {
+    
expect(readRootReleaseVersion('<project><properties><hzb.version>1.8.0</hzb.version></properties></project>')).toBe(
+      '1.8.0',
+    );
+  });
+
+  it('requires server image, web-next image, and web-next build arg to share 
the release version', () => {
+    expect(() =>
+      assertResolvedReleaseImages(
+        [
+          'image: apache/hertzbeat:1.8.0',
+          'image: apache/hertzbeat-web-next:1.8.0',
+          'HERTZBEAT_RELEASE_VERSION: 1.8.0'
+        ].join('\n'),
+        '1.8.0',
+      ),
+    ).not.toThrow();
+
+    expect(() =>
+      assertResolvedReleaseImages('image: apache/hertzbeat:1.8.0\nimage: 
apache/hertzbeat-web-next:old', '1.8.0'),
+    ).toThrow('docker compose config did not resolve');
+  });
+
+  it('checks promotion and rollback versions through the compose config 
runner', () => {
+    const checkedVersions: string[] = [];
+
+    const result = verifyReleaseComposeConfig({
+      releaseVersion: '1.8.0',
+      rollbackVersion: '1.7.9',
+      configRunner: version => {
+        checkedVersions.push(version);
+        return [
+          `image: apache/hertzbeat:${version}`,
+          `image: apache/hertzbeat-web-next:${version}`,
+          `HERTZBEAT_RELEASE_VERSION: ${version}`
+        ].join('\n');
+      }
+    });
+
+    expect(result).toEqual({ releaseVersion: '1.8.0', rollbackVersion: '1.7.9' 
});
+    expect(checkedVersions).toEqual(['1.8.0', '1.7.9']);
+  });
+
+  it('builds a deterministic rollback probe version when no rollback override 
is supplied', () => {
+    expect(rollbackProbeVersion('1.8.0')).toBe('1.8.0-rollback-check');
+    expect(composeConfigEnv('2.0.0', 
{}).HERTZBEAT_RELEASE_VERSION).toBe('2.0.0');
+  });
+});
diff --git a/web-next/scripts/release-readiness-contract.test.ts 
b/web-next/scripts/release-readiness-contract.test.ts
new file mode 100644
index 0000000000..19828c9080
--- /dev/null
+++ b/web-next/scripts/release-readiness-contract.test.ts
@@ -0,0 +1,188 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import { describe, expect, it } from 'vitest';
+
+const webNextRoot = resolve(__dirname, '..');
+const repoRoot = resolve(webNextRoot, '..');
+
+function readWorkflow(): string {
+  return readFileSync(resolve(repoRoot, 
'.github/workflows/frontend-build-test.yml'), 'utf8');
+}
+
+function readDockerfile(): string {
+  return readFileSync(resolve(webNextRoot, 'Dockerfile'), 'utf8');
+}
+
+function readNextObservabilityCompose(): string {
+  return readFileSync(
+    resolve(repoRoot, 
'script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml'),
+    'utf8',
+  );
+}
+
+function readNextConfig(): string {
+  return readFileSync(resolve(webNextRoot, 'next.config.mjs'), 'utf8');
+}
+
+function readReleaseBudgetScript(): string {
+  return readFileSync(resolve(webNextRoot, 'scripts/release-budget.mjs'), 
'utf8');
+}
+
+function readReleaseComposeScript(): string {
+  return readFileSync(resolve(webNextRoot, 'scripts/release-compose.mjs'), 
'utf8');
+}
+
+function readReleaseChecklistScript(): string {
+  return readFileSync(resolve(webNextRoot, 'scripts/release-checklist.mjs'), 
'utf8');
+}
+
+function readNextObservabilityGateway(): string {
+  return readFileSync(
+    resolve(repoRoot, 
'script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf'),
+    'utf8',
+  );
+}
+
+function readRootPom(): string {
+  return readFileSync(resolve(repoRoot, 'pom.xml'), 'utf8');
+}
+
+function readPackageJson(): { scripts?: Record<string, string> } {
+  return JSON.parse(readFileSync(resolve(webNextRoot, 'package.json'), 
'utf8'));
+}
+
+function extractBranches(workflow: string, sectionName: 'push' | 
'pull_request'): string[] {
+  const nextSection = sectionName === 'push' ? 'pull_request' : 'jobs';
+  const sectionPattern = new 
RegExp(`\\n\\s+${sectionName}:([\\s\\S]*?)\\n\\s*${nextSection}:`);
+  const section = workflow.match(sectionPattern)?.[1] ?? '';
+  const branches = section.match(/branches:\s*\[([^\]]+)\]/)?.[1] ?? '';
+
+  return branches
+    .split(',')
+    .map((branch) => branch.trim().replace(/^['"]|['"]$/g, ''))
+    .filter(Boolean);
+}
+
+function extractWorkflowNodeMajors(workflow: string): string[] {
+  return [...workflow.matchAll(/node-version:\s*['"]?(\d+)['"]?/g)].map(match 
=> match[1]);
+}
+
+function extractDockerNodeMajors(dockerfile: string): string[] {
+  return [...dockerfile.matchAll(/FROM\s+node:(\d+)-alpine/g)].map(match => 
match[1]);
+}
+
+function extractXmlTag(xml: string, tagName: string): string {
+  const tagPattern = new RegExp(`<${tagName}>\\s*([^<]+?)\\s*</${tagName}>`);
+  const value = xml.match(tagPattern)?.[1]?.trim();
+  if (!value) {
+    throw new Error(`Missing <${tagName}> in root pom.xml`);
+  }
+  return value;
+}
+
+describe('release-readiness validation baseline', () => {
+  it('keeps frontend CI active for release-readiness working branches', () => {
+    const workflow = readWorkflow();
+
+    expect(extractBranches(workflow, 'push')).toEqual(
+      expect.arrayContaining(['master', 'dev', 'codex/**', 'feature/**', 
'release/**']),
+    );
+    expect(extractBranches(workflow, 'pull_request')).toEqual(
+      expect.arrayContaining(['master', 'dev', 'codex/**', 'feature/**', 
'release/**']),
+    );
+  });
+
+  it('keeps milestone-one parity smoke as a blocking validation command', () 
=> {
+    const workflow = readWorkflow();
+    const packageJson = readPackageJson();
+
+    
expect(packageJson.scripts?.['parity:smoke:baseline']).toContain('--milestone 
1');
+    expect(workflow).toContain('npm run parity:smoke:baseline');
+  });
+
+  it('runs a release bundle budget after the production build in full 
verification', () => {
+    const packageJson = readPackageJson();
+    const releaseBudgetScript = readReleaseBudgetScript();
+
+    expect(packageJson.scripts?.['release:budget']).toBe('node 
./scripts/release-budget.mjs');
+    expect(packageJson.scripts?.['verify:full']).toContain('npm run build && 
npm run release:budget');
+    expect(releaseBudgetScript).toContain('DEFAULT_RELEASE_BUDGET_BYTES');
+    expect(releaseBudgetScript).toContain('evaluateReleaseBudget');
+  });
+
+  it('runs a compose promotion and rollback gate in full verification', () => {
+    const packageJson = readPackageJson();
+    const releaseComposeScript = readReleaseComposeScript();
+
+    expect(packageJson.scripts?.['release:compose']).toBe('node 
./scripts/release-compose.mjs');
+    expect(packageJson.scripts?.['verify:full']).toContain('npm run 
release:budget && npm run release:compose');
+    expect(releaseComposeScript).toContain('verifyReleaseComposeConfig');
+    expect(releaseComposeScript).toContain('HERTZBEAT_ROLLBACK_VERSION');
+    expect(releaseComposeScript).toContain('docker compose');
+  });
+
+  it('runs the release checklist gate and keeps release ingress aligned with 
web-next route families', () => {
+    const packageJson = readPackageJson();
+    const releaseChecklistScript = readReleaseChecklistScript();
+    const gateway = readNextObservabilityGateway();
+
+    expect(packageJson.scripts?.['release:checklist']).toBe('node 
./scripts/release-checklist.mjs');
+    expect(packageJson.scripts?.['verify:full']).toBe(
+      'npm run build && npm run release:budget && npm run release:compose && 
npm run release:checklist && npm run verify',
+    );
+    expect(releaseChecklistScript).toContain('RELEASE_CHECKLIST_ITEMS');
+    expect(releaseChecklistScript).toContain('verifyReleaseChecklist');
+
+    for (const routePrefix of ['/_next/', '/overview', '/entities', '/alert', 
'/topology', '/setting']) {
+      expect(gateway).toContain(`location ^~ ${routePrefix}`);
+    }
+    expect(gateway).toContain('proxy_pass http://web-next:4200;');
+    expect(gateway).toContain('location ^~ /api/');
+    expect(gateway).toContain('proxy_pass http://hertzbeat:1157;');
+  });
+
+  it('keeps the web-next release image aligned with CI Node runtime', () => {
+    const workflowNodeMajors = new 
Set(extractWorkflowNodeMajors(readWorkflow()));
+    const dockerNodeMajors = new 
Set(extractDockerNodeMajors(readDockerfile()));
+
+    expect(workflowNodeMajors).toContain('22');
+    expect(dockerNodeMajors).toEqual(new Set(['22']));
+  });
+
+  it('packages web-next as a standalone non-root healthchecked release image', 
() => {
+    const dockerfile = readDockerfile();
+    const nextConfig = readNextConfig();
+
+    expect(nextConfig).toMatch(/output:\s*['"]standalone['"]/);
+    expect(dockerfile).toContain('COPY --from=builder /app/.next/standalone 
./');
+    expect(dockerfile).toContain('COPY --from=builder /app/.next/static 
./.next/static');
+    expect(dockerfile).toContain('COPY --from=builder /app/public ./public');
+    expect(dockerfile).toContain('USER nextjs');
+    expect(dockerfile).toContain('HEALTHCHECK');
+    expect(dockerfile).toContain('CMD ["node", "server.js"]');
+    expect(dockerfile).not.toContain('npx');
+  });
+
+  it('uses one overrideable release version for the next observability compose 
stack', () => {
+    const releaseVersion = extractXmlTag(readRootPom(), 'hzb.version');
+    const compose = readNextObservabilityCompose();
+    const releaseVersionExpression = 
`\${HERTZBEAT_RELEASE_VERSION:-${releaseVersion}}`;
+
+    expect(compose).toContain(
+      `image: 
"\${HERTZBEAT_SERVER_IMAGE:-apache/hertzbeat}:${releaseVersionExpression}"`,
+    );
+    expect(compose).toContain(
+      `image: 
"\${HERTZBEAT_WEB_NEXT_IMAGE:-apache/hertzbeat-web-next}:${releaseVersionExpression}"`,
+    );
+    expect(compose).toContain(`HERTZBEAT_RELEASE_VERSION: 
${releaseVersionExpression}`);
+    expect(compose).not.toMatch(/image:\s+apache\/hertzbeat:\d/);
+  });
+
+  it('labels the web-next release image with the compose release version', () 
=> {
+    const dockerfile = readDockerfile();
+
+    expect(dockerfile).toContain('ARG HERTZBEAT_RELEASE_VERSION=dev');
+    
expect(dockerfile).toContain('org.opencontainers.image.version="${HERTZBEAT_RELEASE_VERSION}"');
+  });
+});
diff --git a/web-next/scripts/session-security-contract.test.ts 
b/web-next/scripts/session-security-contract.test.ts
new file mode 100644
index 0000000000..5a712f77b9
--- /dev/null
+++ b/web-next/scripts/session-security-contract.test.ts
@@ -0,0 +1,68 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+const WEB_NEXT_ROOT = resolve(__dirname, '..');
+
+function readWebNext(path: string) {
+  return readFileSync(resolve(WEB_NEXT_ROOT, path), 'utf8');
+}
+
+function existsWebNext(path: string) {
+  return existsSync(resolve(WEB_NEXT_ROOT, path));
+}
+
+describe('session security and token boundary contract', () => {
+  it('keeps ui session tokens behind same-origin BFF cookies instead of 
browser localStorage', () => {
+    const apiClientSource = readWebNext('lib/api-client.ts');
+    const loginFormSource = readWebNext('components/pages/login-form.tsx');
+    const authGateSource = readWebNext('components/shell/auth-gate.tsx');
+    const appFrameSource = readWebNext('components/shell/app-frame.tsx');
+    const monitorManageSource = 
readWebNext('app/monitors/monitor-manage-page.tsx');
+
+    expect(apiClientSource).toContain("credentials: 'same-origin'");
+    
expect(apiClientSource).not.toContain("localStorage.getItem('Authorization')");
+    
expect(apiClientSource).not.toContain("localStorage.getItem('refresh-token')");
+    expect(apiClientSource).not.toContain("Authorization: `Bearer ${token}`");
+
+    expect(loginFormSource).toContain('assertSessionLoginSuccess');
+    
expect(loginFormSource).not.toContain('persistLoginTokens(window.localStorage');
+
+    expect(authGateSource).toContain("readClientSessionState()");
+    
expect(authGateSource).not.toContain("localStorage.getItem('Authorization')");
+
+    expect(appFrameSource).toContain("clearClientSession()");
+    
expect(appFrameSource).not.toContain("localStorage.removeItem('Authorization')");
+    
expect(appFrameSource).not.toContain("localStorage.removeItem('refresh-token')");
+
+    expect(monitorManageSource).not.toContain("Authorization: `Bearer 
${token}`");
+    expect(monitorManageSource).not.toContain('getAuthorizationToken');
+
+    [
+      'app/api/[...path]/route.ts',
+      'app/api/account/auth/form/route.ts',
+      'app/api/account/auth/refresh/route.ts',
+      'app/api/account/session/route.ts',
+      'lib/session-bff.ts',
+      'lib/session-client.ts'
+    ].forEach(path => {
+      expect(existsWebNext(path), `${path} should exist`).toBe(true);
+    });
+
+    const sessionBffSource = readWebNext('lib/session-bff.ts');
+    const loginRouteSource = readWebNext('app/api/account/auth/form/route.ts');
+    const proxyRouteSource = readWebNext('app/api/[...path]/route.ts');
+    const nextConfigSource = readWebNext('next.config.mjs');
+
+    expect(sessionBffSource).toContain('httpOnly: true');
+    expect(sessionBffSource).toContain("sameSite: 'lax'");
+    expect(sessionBffSource).toContain('HB_UI_ACCESS_COOKIE');
+    expect(sessionBffSource).toContain('HB_UI_REFRESH_COOKIE');
+    expect(sessionBffSource).toContain('HB_UI_SESSION_MARKER_COOKIE');
+    expect(loginRouteSource).toContain('applySessionCookies');
+    expect(loginRouteSource).toContain('sanitizeSessionPayload');
+    expect(proxyRouteSource).toContain('proxyBackendApiRequest');
+    expect(nextConfigSource).not.toContain("source: '/api/:path*'");
+    expect(nextConfigSource).not.toContain('destination: 
`${backendOrigin}/api/:path*`');
+  });
+});


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to