Align console release verification with build image ref

This commit is contained in:
Haitao Pan 2026-04-12 18:54:56 +08:00
parent 5f1b59be70
commit 318f407222
7 changed files with 97 additions and 38 deletions

View File

@ -186,8 +186,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- prep - prep
- build
- deploy - deploy
if: ${{ always() && needs.deploy.result == 'success' }} if: ${{ always() && needs.deploy.result == 'success' }}
env:
EXPECTED_FRONTEND_IMAGE: ${{ needs.build.outputs.image_ref }}
steps: steps:
- name: Check Out Repository - name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@ -196,4 +199,5 @@ jobs:
run: | run: |
bash scripts/github-actions/verify-frontend-release.sh \ bash scripts/github-actions/verify-frontend-release.sh \
"${CANONICAL_DOMAIN}" \ "${CANONICAL_DOMAIN}" \
"${SERVED_DOMAINS}" "${SERVED_DOMAINS}" \
"${EXPECTED_FRONTEND_IMAGE}"

View File

@ -3,7 +3,7 @@
## Production Baseline ## Production Baseline
- Runtime: `Caddy + Docker Compose` - Runtime: `Caddy + Docker Compose`
- Deploy host: `root@cn-console.svc.plus` - Deploy host: `root@jp-xhttp-contabo.svc.plus`
- Public domains: - Public domains:
- `www.svc.plus` - `www.svc.plus`
- `console.svc.plus` - `console.svc.plus`
@ -31,7 +31,7 @@ The release contract now uses:
Validation must pass for both domains. A release is incomplete if either host serves a different runtime version, static asset family, or `dashboardUrl`. Validation must pass for both domains. A release is incomplete if either host serves a different runtime version, static asset family, or `dashboardUrl`.
This baseline is intentional for the weak-IO single-node host `root@cn-console.svc.plus`. No images are built on the target machine, keeping the deployment lightweight: the host only logs into GHCR, pulls the `dashboard` image, extracts assets into `frontend_static`, and starts `dashboard` plus `caddy` containers via `docker compose`. This baseline is intentional for the weak-IO single-node host `root@jp-xhttp-contabo.svc.plus`. No images are built on the target machine, keeping the deployment lightweight: the host only logs into GHCR, pulls the `dashboard` image, extracts assets into `frontend_static`, and starts `dashboard` plus `caddy` containers via `docker compose`.
`docs.svc.plus` is now the dedicated docs/blog service for the frontend delivery path. `docs.svc.plus` is now the dedicated docs/blog service for the frontend delivery path.

View File

@ -4,7 +4,7 @@
- Runtime: `console.svc.plus` - Runtime: `console.svc.plus`
- Topology: `Caddy + Docker Compose + GitHub Actions` - Topology: `Caddy + Docker Compose + GitHub Actions`
- Deploy host: `root@cn-console.svc.plus` - Deploy host: `root@jp-xhttp-contabo.svc.plus`
- Public domains: - Public domains:
- `https://www.svc.plus` - `https://www.svc.plus`
- `https://console.svc.plus` - `https://console.svc.plus`
@ -24,7 +24,7 @@ This is intentionally static-first for the current weak-IO single-node host. Dyn
## Control Plane & DNS Stage ## Control Plane & DNS Stage
The control repo (`github-org-x-evor`) tracks `console.svc.plus` through `console.svc.plus.code-workspace` and keeps the `subrepos/accounts.svc.plus` pointer in sync via `skills/cross-repo-upstream-submodule-sync`. Releases resolve metadata with that workspace and the `config/single-node-release` manifests. After `.github/workflows/pipeline.yaml` finishes pushing the new image, the control-plane DNS automation calls `scripts/github-actions/update-release-dns.sh` to update Cloudflare DNS so the new endpoint is reachable under `cn-console.svc.plus`. The control repo (`github-org-x-evor`) tracks `console.svc.plus` through `console.svc.plus.code-workspace` and keeps the `subrepos/accounts.svc.plus` pointer in sync via `skills/cross-repo-upstream-submodule-sync`. Releases resolve metadata with that workspace and the `config/single-node-release` manifests. After `.github/workflows/pipeline.yaml` finishes pushing the new image, the control-plane DNS automation calls `scripts/github-actions/update-release-dns.sh` to update Cloudflare DNS so the new endpoint is reachable through the current production host `jp-xhttp-contabo.svc.plus`.
## Runtime Layout ## Runtime Layout
@ -124,9 +124,9 @@ PY
Remote checks: Remote checks:
```bash ```bash
ssh root@cn-console.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime ps" ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime ps"
ssh root@cn-console.svc.plus "curl -fsSI -H 'Host: www.svc.plus' http://127.0.0.1/" ssh root@jp-xhttp-contabo.svc.plus "curl -fsSI -H 'Host: www.svc.plus' http://127.0.0.1/"
ssh root@cn-console.svc.plus "curl -fsSI -H 'Host: console.svc.plus' http://127.0.0.1/" ssh root@jp-xhttp-contabo.svc.plus "curl -fsSI -H 'Host: console.svc.plus' http://127.0.0.1/"
curl -fsSIL https://www.svc.plus curl -fsSIL https://www.svc.plus
curl -fsSIL https://console.svc.plus curl -fsSIL https://console.svc.plus
``` ```
@ -147,8 +147,8 @@ curl -fsSIL https://console.svc.plus
3. Restart the services: 3. Restart the services:
```bash ```bash
ssh root@cn-console.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime run --rm frontend-assets" ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime run --rm frontend-assets"
ssh root@cn-console.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime up -d dashboard caddy" ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime up -d dashboard caddy"
``` ```
4. Verify `https://www.svc.plus` and `https://console.svc.plus` again before closing the incident. 4. Verify `https://www.svc.plus` and `https://console.svc.plus` again before closing the incident.

View File

@ -3,7 +3,7 @@
## 生产基线 ## 生产基线
- 运行拓扑: `Caddy + Docker Compose` - 运行拓扑: `Caddy + Docker Compose`
- 目标主机: `root@cn-console.svc.plus` - 目标主机: `root@jp-xhttp-contabo.svc.plus`
- 域名: - 域名:
- `www.svc.plus` - `www.svc.plus`
- `console.svc.plus` - `console.svc.plus`
@ -22,9 +22,9 @@
- Next.js standalone 容器只承接动态页面、认证接口和代理接口,`frontend-assets` 任务会把所有静态文件(包括哈希后的 CSS/JS拷贝到 `frontend_static` - Next.js standalone 容器只承接动态页面、认证接口和代理接口,`frontend-assets` 任务会把所有静态文件(包括哈希后的 CSS/JS拷贝到 `frontend_static`
- `docs.svc.plus` 是 docs/blog 的运行时内容源,浏览器不会直接访问它。 - `docs.svc.plus` 是 docs/blog 的运行时内容源,浏览器不会直接访问它。
发布由 `.github/workflows/pipeline.yaml` 驱动CI 构建/推送镜像、渲染包含 `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL``.env.runtime`,然后将 `docker-compose.yml`、`Caddyfile` 与运行时环境文件发送到主机。随后控制平面 DNS 自动化会通过 `scripts/github-actions/update-release-dns.sh` 更新 Cloudflare DNS使 `cn-console.svc.plus` 指向更新后的环境。 发布由 `.github/workflows/pipeline.yaml` 驱动CI 构建/推送镜像、渲染包含 `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL``.env.runtime`,然后将 `docker-compose.yml`、`Caddyfile` 与运行时环境文件发送到主机。随后控制平面 DNS 自动化会通过 `scripts/github-actions/update-release-dns.sh` 更新 Cloudflare DNS使当前生产主机 `jp-xhttp-contabo.svc.plus` 承载更新后的环境。
这是针对弱 IO 单机主机 `root@cn-console.svc.plus` 的部署权衡:主机不会在本地构建镜像,只需登录 GHCR、拉取 `dashboard` 镜像、解包静态资源到 `frontend_static`,再通过 `docker compose` 启动 `dashboard``caddy` 这是针对弱 IO 单机主机 `root@jp-xhttp-contabo.svc.plus` 的部署权衡:主机不会在本地构建镜像,只需登录 GHCR、拉取 `dashboard` 镜像、解包静态资源到 `frontend_static`,再通过 `docker compose` 启动 `dashboard``caddy`
`docs.svc.plus` 已经是前端 docs/blog 内容的独立服务。 `docs.svc.plus` 已经是前端 docs/blog 内容的独立服务。

View File

@ -4,7 +4,7 @@ set -euo pipefail
IMAGE_TAG_INPUT="${1-}" IMAGE_TAG_INPUT="${1-}"
IMAGE_TAG="${IMAGE_TAG_INPUT}" IMAGE_TAG="${IMAGE_TAG_INPUT}"
if [[ -z "${IMAGE_TAG}" ]]; then if [[ -z "${IMAGE_TAG}" ]]; then
IMAGE_TAG="${GITHUB_SHA::7}" IMAGE_TAG="${GITHUB_SHA:?GITHUB_SHA is required}"
fi fi
GHCR_NAMESPACE="${GITHUB_REPOSITORY_OWNER,,}" GHCR_NAMESPACE="${GITHUB_REPOSITORY_OWNER,,}"

View File

@ -1,8 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
CANONICAL_DOMAIN="${1:?usage: verify-frontend-release.sh <canonical-domain> <served-domains>}" CANONICAL_DOMAIN="${1:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref>}"
SERVED_DOMAINS="${2:?usage: verify-frontend-release.sh <canonical-domain> <served-domains>}" SERVED_DOMAINS="${2:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref>}"
EXPECTED_IMAGE_REF="${3:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref>}"
EXPECTED_DASHBOARD_URL="https://${CANONICAL_DOMAIN}" EXPECTED_DASHBOARD_URL="https://${CANONICAL_DOMAIN}"
curl_headers=( curl_headers=(
@ -18,6 +19,35 @@ trim() {
printf '%s' "${value}" printf '%s' "${value}"
} }
parse_image_ref() {
local image_ref="$1"
IMAGE_REF="${image_ref}" python3 - <<'PY'
import os
import re
import sys
image_ref = os.environ["IMAGE_REF"].strip()
match = re.search(r":([^:@]+)$", image_ref)
tag = match.group(1) if match else ""
commit = ""
if re.fullmatch(r"[0-9a-f]{7,40}", tag, flags=re.IGNORECASE):
commit = tag
else:
prefixed_match = re.fullmatch(r"sha-([0-9a-f]{7,40})", tag, flags=re.IGNORECASE)
if prefixed_match:
commit = prefixed_match.group(1)
if not image_ref or not tag or not commit:
sys.exit(1)
print(image_ref)
print(tag)
print(commit)
PY
}
parse_release_metadata() { parse_release_metadata() {
local payload="$1" local payload="$1"
RELEASE_PAYLOAD="${payload}" python3 - <<'PY' RELEASE_PAYLOAD="${payload}" python3 - <<'PY'
@ -101,6 +131,16 @@ verify_domain() {
printf '%s\t%s\t%s\t%s\t%s\n' "${domain}" "${actual_image_ref}" "${actual_image_tag}" "${actual_release_commit}" "${actual_dashboard_url}" printf '%s\t%s\t%s\t%s\t%s\n' "${domain}" "${actual_image_ref}" "${actual_image_tag}" "${actual_release_commit}" "${actual_dashboard_url}"
} }
mapfile -t expected_release_lines < <(parse_image_ref "${EXPECTED_IMAGE_REF}")
EXPECTED_RELEASE_IMAGE_REF="${expected_release_lines[0]-}"
EXPECTED_RELEASE_IMAGE_TAG="${expected_release_lines[1]-}"
EXPECTED_RELEASE_COMMIT="${expected_release_lines[2]-}"
if [[ -z "${EXPECTED_RELEASE_IMAGE_REF}" || -z "${EXPECTED_RELEASE_IMAGE_TAG}" || -z "${EXPECTED_RELEASE_COMMIT}" ]]; then
echo "Expected image ref is invalid: ${EXPECTED_IMAGE_REF}" >&2
exit 1
fi
mapfile -t served_domains < <( mapfile -t served_domains < <(
printf '%s' "${SERVED_DOMAINS}" | tr ',' '\n' | while IFS= read -r domain; do printf '%s' "${SERVED_DOMAINS}" | tr ',' '\n' | while IFS= read -r domain; do
domain="$(trim "${domain}")" domain="$(trim "${domain}")"
@ -138,6 +178,21 @@ reference_dashboard_url=""
for row in "${verification_rows[@]}"; do for row in "${verification_rows[@]}"; do
IFS=$'\t' read -r domain actual_image_ref actual_image_tag actual_release_commit actual_dashboard_url <<< "${row}" IFS=$'\t' read -r domain actual_image_ref actual_image_tag actual_release_commit actual_dashboard_url <<< "${row}"
if [[ "${actual_image_ref}" != "${EXPECTED_RELEASE_IMAGE_REF}" ]]; then
echo "Release image mismatch for ${domain}: expected ${EXPECTED_RELEASE_IMAGE_REF}, got ${actual_image_ref}" >&2
exit 1
fi
if [[ "${actual_image_tag}" != "${EXPECTED_RELEASE_IMAGE_TAG}" ]]; then
echo "Release tag mismatch for ${domain}: expected ${EXPECTED_RELEASE_IMAGE_TAG}, got ${actual_image_tag}" >&2
exit 1
fi
if [[ "${actual_release_commit}" != "${EXPECTED_RELEASE_COMMIT}" ]]; then
echo "Release commit mismatch for ${domain}: expected ${EXPECTED_RELEASE_COMMIT}, got ${actual_release_commit}" >&2
exit 1
fi
if [[ -z "${reference_image_ref}" ]]; then if [[ -z "${reference_image_ref}" ]]; then
reference_image_ref="${actual_image_ref}" reference_image_ref="${actual_image_ref}"
reference_image_tag="${actual_image_tag}" reference_image_tag="${actual_image_tag}"
@ -145,24 +200,4 @@ for row in "${verification_rows[@]}"; do
reference_dashboard_url="${actual_dashboard_url}" reference_dashboard_url="${actual_dashboard_url}"
continue continue
fi fi
if [[ "${actual_image_ref}" != "${reference_image_ref}" ]]; then
echo "Release image mismatch across served domains: ${domain} returned ${actual_image_ref}, expected ${reference_image_ref}" >&2
exit 1
fi
if [[ "${actual_image_tag}" != "${reference_image_tag}" ]]; then
echo "Release tag mismatch across served domains: ${domain} returned ${actual_image_tag}, expected ${reference_image_tag}" >&2
exit 1
fi
if [[ "${actual_release_commit}" != "${reference_release_commit}" ]]; then
echo "Release commit mismatch across served domains: ${domain} returned ${actual_release_commit}, expected ${reference_release_commit}" >&2
exit 1
fi
if [[ "${actual_dashboard_url}" != "${reference_dashboard_url}" ]]; then
echo "dashboardUrl mismatch across served domains: ${domain} returned ${actual_dashboard_url}, expected ${reference_dashboard_url}" >&2
exit 1
fi
done done

View File

@ -2,11 +2,31 @@ import { NextResponse } from 'next/server'
import { loadRuntimeConfig } from '@server/runtime-loader' import { loadRuntimeConfig } from '@server/runtime-loader'
function resolveReleaseImageMetadata(frontendImage: string | undefined) { type TReleaseImageMetadata = {
releaseImageRef: string | null
releaseImageTag: string | null
releaseCommit: string | null
}
function resolveReleaseCommit(releaseImageTag: string | null): string | null {
if (!releaseImageTag) {
return null
}
const normalizedTag = releaseImageTag.trim()
const prefixedShaMatch = normalizedTag.match(/^sha-([0-9a-f]{7,40})$/i)
if (prefixedShaMatch) {
return prefixedShaMatch[1] ?? null
}
return /^[0-9a-f]{7,40}$/i.test(normalizedTag) ? normalizedTag : null
}
function resolveReleaseImageMetadata(frontendImage: string | undefined): TReleaseImageMetadata {
const releaseImageRef = frontendImage?.trim() || null const releaseImageRef = frontendImage?.trim() || null
const releaseImageTagMatch = releaseImageRef?.match(/:([^:@]+)$/) const releaseImageTagMatch = releaseImageRef?.match(/:([^:@]+)$/)
const releaseImageTag = releaseImageTagMatch?.[1] ?? null const releaseImageTag = releaseImageTagMatch?.[1] ?? null
const releaseCommit = releaseImageTag && /^[0-9a-f]{7,40}$/i.test(releaseImageTag) ? releaseImageTag : null const releaseCommit = resolveReleaseCommit(releaseImageTag)
return { return {
releaseImageRef, releaseImageRef,