From 585c602000be239b8e9237d315d108db7352cb11 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 26 Oct 2026 20:20:50 +0800 Subject: [PATCH] Use coordinated docs port with conflict checks and deployment-independent cache keys --- .gitea/workflows/ci-deploy.yml | 2 +- AGENTS.md | 2 +- README.md | 10 +++++----- content/docs/01-getting-started.md | 2 +- docker-compose.ces-control.yml | 2 +- scripts/ci.py | 4 +++- scripts/deploy.py | 25 +++++++++++++++++++++-- scripts/test_ci.py | 3 +++ scripts/test_deploy.py | 32 ++++++++++++++++++++++++++++++ 9 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 scripts/test_deploy.py diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml index 733e4c6..3b252a4 100644 --- a/.gitea/workflows/ci-deploy.yml +++ b/.gitea/workflows/ci-deploy.yml @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index ee2068a..ab3fbac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 。SSH 使用隔离配置:`ssh -F /dev/null -o IdentitiesOnly=yes -i /Users/zzyang/.codex/credentials/gitea-entroia/codex_entroia_ed25519 -p 22222`。 - 每个可验证阶段完成后提交并推送当前远程分支;远程尚未创建时保留本地提交并说明原因。不借用其他账户、不强推。 diff --git a/README.md b/README.md index 402c06a..fc509b9 100644 --- a/README.md +++ b/README.md @@ -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--<摘要>` 镜像时跳过依赖安装和 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/`,`current` 指向最近成功发布。修改服务器入口配置后手动重跑 main 工作流即可,复用现有镜像。 diff --git a/content/docs/01-getting-started.md b/content/docs/01-getting-started.md index 4e48284..166a664 100644 --- a/content/docs/01-getting-started.md +++ b/content/docs/01-getting-started.md @@ -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 只验证,不部署。 diff --git a/docker-compose.ces-control.yml b/docker-compose.ces-control.yml index 18569d1..13c428b 100644 --- a/docker-compose.ces-control.yml +++ b/docker-compose.ces-control.yml @@ -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: diff --git a/scripts/ci.py b/scripts/ci.py index 912f137..a85c831 100644 --- a/scripts/ci.py +++ b/scripts/ci.py @@ -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" diff --git a/scripts/deploy.py b/scripts/deploy.py index 15f44e8..a8b9475 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -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 diff --git a/scripts/test_ci.py b/scripts/test_ci.py index 37c1328..264d522 100644 --- a/scripts/test_ci.py +++ b/scripts/test_ci.py @@ -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()) diff --git a/scripts/test_deploy.py b/scripts/test_deploy.py new file mode 100644 index 0000000..8483e09 --- /dev/null +++ b/scripts/test_deploy.py @@ -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()