Use coordinated docs port with conflict checks and deployment-independent cache keys
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 4m6s
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 4m6s
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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`。
|
||||
- 每个可验证阶段完成后提交并推送当前远程分支;远程尚未创建时保留本地提交并说明原因。不借用其他账户、不强推。
|
||||
|
||||
|
||||
@@ -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 工作流即可,复用现有镜像。
|
||||
|
||||
@@ -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 只验证,不部署。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user