Use coordinated docs port with conflict checks and deployment-independent cache keys
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 4m6s

This commit is contained in:
2026-10-26 20:20:50 +08:00
parent 4a98b84aad
commit 585c602000
9 changed files with 70 additions and 12 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: Verify with persistent caches or reuse verified image
run: python3 scripts/ci.py
- name: Deploy latest main with health checks and rollback
if: false # Hold deployment until the shared-server port is confirmed.
if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request'
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ gitea.token }}
+1 -1
View File
@@ -5,7 +5,7 @@
- 使用 pnpm 管理 JavaScript workspace,使用 uv 管理 `tools/figures` 的 Python 项目,保留两套锁文件。
- Markdown 正文放在 `content/docs`,Python 图形源码放在 `tools/figures/src/endor_figures`,可复现的 SVG 输出放在 `apps/docs/public/figures`。
- 未确认的架构提议明确标注为草案,不能表述为现有实现。
- 本地开发与预览只监听 `127.0.0.1`。部署容器监听 `0.0.0.0:3000`,服务器入口默认 18132,可通过独立 production.env 配置。
- 本地开发与预览只监听 `127.0.0.1`。部署容器监听 `0.0.0.0:3000`,服务器入口默认 18135,可通过独立 production.env 配置。
- 对 gitea.entroia.com 仅使用 codex-bot,提交身份为 Codex Bot <codex-bot@gitea.entroia.local>。SSH 使用隔离配置:`ssh -F /dev/null -o IdentitiesOnly=yes -i /Users/zzyang/.codex/credentials/gitea-entroia/codex_entroia_ed25519 -p 22222`。
- 每个可验证阶段完成后提交并推送当前远程分支;远程尚未创建时保留本地提交并说明原因。不借用其他账户、不强推。
+5 -5
View File
@@ -39,19 +39,19 @@ main 推送通过 CI 验证后自动部署;Pull Request 只验证,不部署
`.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。
1. 对实际构建输入计算 SHA-256:前端源码、Markdown、图片、Python 制图源码、依赖锁文件、Dockerfile 和构建/部署脚本。
1. 对实际构建输入计算 SHA-256:前端源码、Markdown、图片、Python 制图源码、依赖锁文件、Dockerfile 和构建脚本。
2. 命中 `verified-<scope>-<摘要>` 镜像时跳过依赖安装和 Next.js build,只做实际路由与资源冒烟检查。
3. 输入变化时运行 lint、类型检查、制图一致性检查与构建。pnpm store、uv 和 `.next/cache` 使用持久 Docker 卷,main 与每个 PR 独立。
3. 输入变化时运行 lint、类型检查、制图一致性检查与构建。pnpm store、uv 和 `.next/cache` 使用持久 Docker 卷,main 与每个 PR 独立;额外保留 Next.js SWC 下载缓存。
4. 生产镜像只包含 Next.js standalone 产物;Python 留在检查容器,不进入运行镜像。镜像通过健康、全部文档、JS/CSS、SVG 和 404 检查后才标记为 verified。
5. 部署前检查是否仍为最新 main,使用 `--no-build` 启动,失败恢复上一版镜像。
README 或工作流等非构建输入变化不会触发 Next.js 重建;Markdown 正文变化会更新静态页面,仍需要一次增量构建。首次构建必须安装依赖并完整生成产物。缓存可能因基础镜像、工具链或锁文件变化而失效;不要清理共享 Docker 缓存或卷。
根 README、工作流或部署配置等非构建输入变化不会触发 Next.js 重建;Markdown 正文变化会更新静态页面,仍需要一次增量构建。首次构建必须安装依赖并完整生成产物。缓存可能因基础镜像、工具链或锁文件变化而失效;不要清理共享 Docker 缓存或卷。
默认入口为服务器的 **18132** 端口。容器监听 3000,使用独立 Compose 项目 `endorphin-tabularium-docs`。服务器配置位于 `/etc/endorphin-tabularium-docs/production.env`:
默认入口为服务器的 **18135** 端口。容器监听 3000,使用独立 Compose 项目 `endorphin-tabularium-docs`。部署前先检查端口是否空闲(或属于本服务),不会停止其他服务以抢占端口。服务器配置位于 `/etc/endorphin-tabularium-docs/production.env`:
```dotenv
TABULARIUM_BIND=0.0.0.0
TABULARIUM_PORT=18132
TABULARIUM_PORT=18135
```
发布记录位于 `/opt/endorphin-tabularium-docs/releases/<commit>`,`current` 指向最近成功发布。修改服务器入口配置后手动重跑 main 工作流即可,复用现有镜像。
+1 -1
View File
@@ -27,4 +27,4 @@ pnpm check
pnpm start
```
本地开发入口只监听 127.0.0.1。main 推送会在 ces-control 上验证并自动部署,服务器入口默认 18132。相同构建输入复用已验证镜像;源码变化时复用依赖与 Next.js 缓存。Pull Request 只验证,不部署。
本地开发入口只监听 127.0.0.1。main 推送会在 ces-control 上验证并自动部署,服务器入口默认 18135。相同构建输入复用已验证镜像;源码变化时复用依赖与 Next.js 缓存。Pull Request 只验证,不部署。
+1 -1
View File
@@ -4,7 +4,7 @@ services:
image: ${TABULARIUM_IMAGE:?verified image required}
init: true
ports:
- "${TABULARIUM_BIND:-0.0.0.0}:${TABULARIUM_PORT:-18132}:3000"
- "${TABULARIUM_BIND:-0.0.0.0}:${TABULARIUM_PORT:-18135}:3000"
restart: unless-stopped
stop_grace_period: 20s
security_opt:
+3 -1
View File
@@ -13,7 +13,8 @@ import time
ROOT = Path(__file__).resolve().parents[1]
APP = "endorphin-tabularium-docs"
INPUTS = ["Dockerfile", "Dockerfile.ci", ".dockerignore", ".npmrc", "package.json",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "apps/docs", "content", "tools/figures", "scripts"]
"pnpm-lock.yaml", "pnpm-workspace.yaml", "apps/docs", "content", "tools/figures",
"scripts/check-container.sh", "scripts/ci.py"]
def run(*args: str, capture: bool = False) -> str:
@@ -82,6 +83,7 @@ def main() -> None:
"--mount", f"type=volume,src={APP}-{scope}-pnpm,dst=/pnpm/store",
"--mount", f"type=volume,src={APP}-{scope}-uv,dst=/root/.cache/uv",
"--mount", f"type=volume,src={APP}-{scope}-next,dst=/workspace/apps/docs/.next/cache",
"--mount", f"type=volume,src={APP}-{scope}-swc,dst=/root/.cache/next-swc",
checks)
run("docker", "cp", f"{name}:/workspace/apps/docs/.next/standalone/.", str(output))
app = output / "apps/docs"
+23 -2
View File
@@ -5,6 +5,7 @@ import os
from pathlib import Path
import re
import shutil
import socket
import subprocess
import urllib.request
from ci import APP, ROOT, fingerprint, run
@@ -29,6 +30,25 @@ def compose(release: Path, *args: str) -> None:
"--env-file", str(release / "release.env"), "-f", str(release / "compose.yml"), *args)
def check_port(port: int) -> None:
# An existing container belonging to this Compose service may retain its port.
own = run("docker", "ps", "-q", "--filter", f"label=com.docker.compose.project={APP}",
"--filter", "label=com.docker.compose.service=docs", capture=True)
if own:
for container in own.splitlines():
details = json.loads(run("docker", "inspect", container, capture=True))[0]
ports = details["NetworkSettings"]["Ports"].get("3000/tcp") or []
if any(item["HostPort"] == str(port) for item in ports):
print(f"Port {port} belongs to the current Tabularium service", flush=True)
return
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
try:
probe.bind(("0.0.0.0", port))
except OSError as error:
raise RuntimeError(f"Port {port} is already occupied; no service was changed") from error
print(f"Port {port} is available", flush=True)
def main() -> None:
if os.geteuid() != 0:
raise RuntimeError("Deployment requires root on ces-control")
@@ -48,13 +68,14 @@ def main() -> None:
run("docker", "image", "inspect", actual, capture=True)
CONFIG.parent.mkdir(parents=True, exist_ok=True)
if not CONFIG.exists():
CONFIG.write_text("TABULARIUM_BIND=0.0.0.0\nTABULARIUM_PORT=18132\n")
CONFIG.write_text("TABULARIUM_BIND=0.0.0.0\nTABULARIUM_PORT=18135\n")
CONFIG.chmod(0o600)
settings = dict(line.split("=", 1) for line in CONFIG.read_text().splitlines()
if line and not line.startswith("#"))
port = int(settings.get("TABULARIUM_PORT", "18132"))
port = int(settings.get("TABULARIUM_PORT", "18135"))
if not 1024 <= port <= 65535:
raise ValueError("Invalid port")
check_port(port)
current = DEPLOY_ROOT / "current"
previous = current.resolve() if current.is_symlink() else None
release = DEPLOY_ROOT / "releases" / sha
+3
View File
@@ -24,6 +24,9 @@ class FingerprintTests(unittest.TestCase):
cache = root / "apps/docs/.next/cache/state"
cache.parent.mkdir(parents=True)
cache.write_text("compiler cache\n")
deploy = root / "scripts/deploy.py"
deploy.parent.mkdir(parents=True)
deploy.write_text("# deployment-only configuration\n")
self.assertEqual(original, ci.fingerprint())
doc.write_text("# Updated\n")
self.assertNotEqual(original, ci.fingerprint())
+32
View File
@@ -0,0 +1,32 @@
"""Ensure port coordination never treats another service's port as ours."""
import json
import unittest
from unittest.mock import MagicMock, patch
import deploy
class PortGuardTests(unittest.TestCase):
def test_foreign_listener_blocks_deployment(self):
probe = MagicMock()
probe.__enter__.return_value.bind.side_effect = OSError("Address already in use")
with patch.object(deploy, "run", return_value=""), patch.object(deploy.socket, "socket", return_value=probe):
with self.assertRaisesRegex(RuntimeError, "already occupied"):
deploy.check_port(18135)
def test_current_service_can_keep_its_port(self):
details = [{"NetworkSettings": {"Ports": {"3000/tcp": [{"HostPort": "18135"}]}}}]
with patch.object(deploy, "run", side_effect=["our-container", json.dumps(details)]), patch.object(deploy.socket, "socket") as probe:
deploy.check_port(18135)
probe.assert_not_called()
def test_current_service_on_another_port_does_not_bypass_conflict(self):
details = [{"NetworkSettings": {"Ports": {"3000/tcp": [{"HostPort": "18134"}]}}}]
probe = MagicMock()
probe.__enter__.return_value.bind.side_effect = OSError("Address already in use")
with patch.object(deploy, "run", side_effect=["our-container", json.dumps(details)]), patch.object(deploy.socket, "socket", return_value=probe):
with self.assertRaisesRegex(RuntimeError, "already occupied"):
deploy.check_port(18135)
if __name__ == "__main__":
unittest.main()