Deploy Tabularium on ces-control with verified image reuse and persistent caches
Verify and deploy Tabularium / verify-and-deploy (push) Waiting to run

This commit is contained in:
2026-10-26 20:06:30 +08:00
parent 5ceb4e57aa
commit 1eabfcfb21
18 changed files with 421 additions and 11 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
**/node_modules
**/.next
**/.venv
**/__pycache__
**/*.pyc
**/*.tsbuildinfo
.artifacts
.env*
+33
View File
@@ -0,0 +1,33 @@
name: Verify and deploy Tabularium
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: tabularium-${{ gitea.ref }}
cancel-in-progress: false
jobs:
verify-and-deploy:
runs-on: ces-control
timeout-minutes: 45
env:
DOCKER_BUILDKIT: "0"
CI_CACHE_SCOPE: ${{ gitea.event_name == 'pull_request' && format('pr-{0}', gitea.event.pull_request.number) || 'main' }}
steps:
- name: Checkout project
uses: actions/checkout@v4
with:
github-server-url: ${{ gitea.server_url }}
persist-credentials: false
- name: Verify with persistent caches or reuse verified image
run: python3 scripts/ci.py
- name: Deploy latest main with health checks and rollback
if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request'
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ gitea.token }}
TABULARIUM_RELEASE_SHA: ${{ gitea.sha }}
run: python3 scripts/deploy.py
+2
View File
@@ -9,3 +9,5 @@ __pycache__/
.DS_Store
.env*
!.env.example
.artifacts/
+4 -2
View File
@@ -1,10 +1,12 @@
# Tabularium working agreements
- 本仓库专门维护 Endor 文档与制图脚本。所有开发、构建、制图、检查在本地执行,不添加远程 CI、部署、托管或容器运行流程。
- 本仓库专门维护 Endor 文档与制图脚本。支持本地开发;CI/CD 使用现有 ces-control Runner,在 main 通过验证后自动部署。不得修改其他项目、共享 Runner 配置或清理共享 Docker 缓存。
- Web 技术栈为 TypeScript、React、Tailwind CSS、Next.js Pages Router;不要切换到 App Router。
- 使用 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`。
- 本地开发与预览只监听 `127.0.0.1`。部署容器监听 `0.0.0.0:3000`,服务器入口默认 18132,可通过独立 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`。
- 每个可验证阶段完成后提交并推送当前远程分支;远程尚未创建时保留本地提交并说明原因。不借用其他账户、不强推。
- 构建按输入内容摘要复用已验证镜像。pnpm、uv、Next.js 缓存按 main/PR 隔离并持久保留;部署与回退只使用 `--no-build`。
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-bookworm-slim
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
WORKDIR /app
# Context is the verified standalone artifact, never the source checkout.
COPY --chown=node:node . .
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=6 \
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node", "apps/docs/server.js"]
+15
View File
@@ -0,0 +1,15 @@
FROM ghcr.io/astral-sh/uv:0.11.3 AS uv
FROM node:22-bookworm-slim AS toolchain
COPY --from=uv /uv /usr/local/bin/uv
RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& npm install --global pnpm@11.25.0
ENV NEXT_TELEMETRY_DISABLED=1 UV_PYTHON_DOWNLOADS=never
WORKDIR /workspace
FROM toolchain AS checks
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY apps/docs ./apps/docs
COPY content ./content
COPY tools/figures ./tools/figures
COPY scripts ./scripts
CMD ["sh", "scripts/check-container.sh"]
+27 -5
View File
@@ -1,11 +1,11 @@
# Endor · Tabularium
Endor 文档与制图 monorepo。Tabularium 取古罗马档案馆之意。
Endor 文档与制图 monorepo。Tabularium 取古罗马档案馆之意。支持本地开发,并由 Gitea Actions 在 ces-control 上验证与部署。
## 本地开发
需要 Node.js 22.13+(推荐 LTS)、pnpm 11.25、Python 3.11+ 和 uv。
首次安装依赖需要访问软件包源;站点、制图和检查均在本机运行。
首次安装依赖需要访问软件包源。本地开发服务只监听 127.0.0.1。
```sh
pnpm install --frozen-lockfile
@@ -33,9 +33,31 @@ tools/figures/ 独立 Python 项目,使用 drawsvg 编程制图
文档标题取首个 `# 标题`,文档排序按文件名。普通 Markdown 与 GFM 表格可用,嵌入图使用 `/figures/<name>.svg`。不解析原始 HTML。
生产构建会生成文档路由;新增或修改文档后重新运行 build。开发模式按请求读取内容。
所有工作流只在本地执行,不配置 CI 或部署。远程 Git 仅用于保存和同步源码。
main 推送通过 CI 验证后自动部署;Pull Request 只验证,不部署。工作流也支持手动触发。
## CI/CD 与缓存
`.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。
1. 对实际构建输入计算 SHA-256:前端源码、Markdown、图片、Python 制图源码、依赖锁文件、Dockerfile 和构建/部署脚本。
2. 命中 `verified-<scope>-<摘要>` 镜像时跳过依赖安装和 Next.js build,只做实际路由与资源冒烟检查。
3. 输入变化时运行 lint、类型检查、制图一致性检查与构建。pnpm store、uv 和 `.next/cache` 使用持久 Docker 卷,main 与每个 PR 独立。
4. 生产镜像只包含 Next.js standalone 产物;Python 留在检查容器,不进入运行镜像。镜像通过健康、全部文档、JS/CSS、SVG 和 404 检查后才标记为 verified。
5. 部署前检查是否仍为最新 main,使用 `--no-build` 启动,失败恢复上一版镜像。
README 或工作流等非构建输入变化不会触发 Next.js 重建;Markdown 正文变化会更新静态页面,仍需要一次增量构建。首次构建必须安装依赖并完整生成产物。缓存可能因基础镜像、工具链或锁文件变化而失效;不要清理共享 Docker 缓存或卷。
默认入口为服务器的 **18132** 端口。容器监听 3000,使用独立 Compose 项目 `endorphin-tabularium-docs`。服务器配置位于 `/etc/endorphin-tabularium-docs/production.env`:
```dotenv
TABULARIUM_BIND=0.0.0.0
TABULARIUM_PORT=18132
```
发布记录位于 `/opt/endorphin-tabularium-docs/releases/<commit>`,`current` 指向最近成功发布。修改服务器入口配置后手动重跑 main 工作流即可,复用现有镜像。
健康检查:`/api/health`。本项目不修改 Hestia、PROMPT 或其他服务,也不自动修改防火墙、域名和反向代理。
## Git 远程
请创建空仓库 `endorphin-tabularium-docs`,不要初始化 README 或许可证。
得到实际仓库地址后再添加 origin 并推送。不要假定仓库所有者或使用其他用户凭据。
仓库:[endorphin-tabularium-docs](https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs)。开发者 Git 操作使用 codex-bot 专用 SSH;Actions 检出使用 Gitea 内置任务令牌,不保存检出凭据。
+3
View File
@@ -1,6 +1,9 @@
import path from "node:path";
import type { NextConfig } from "next";
const config: NextConfig = {
output: "standalone",
outputFileTracingRoot: path.resolve(process.cwd(), "../.."),
reactStrictMode: true,
poweredByHeader: false,
};
+6
View File
@@ -0,0 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default function health(_request: NextApiRequest, response: NextApiResponse) {
response.setHeader("Cache-Control", "no-store");
response.status(200).json({ status: "ok" });
}
+2 -2
View File
@@ -10,11 +10,11 @@ export const getStaticProps: GetStaticProps<{ docs: DocLink[] }> = async () => (
export default function Home({ docs }: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<>
<Head><title>Tabularium · Endor 文档</title><meta name="description" content="Endor 的本地文档、架构设计与制图档案。" /></Head>
<Head><title>Tabularium · Endor 文档</title><meta name="description" content="Endor 的文档、架构设计与制图档案。" /></Head>
<main className="mx-auto max-w-5xl px-6 py-16 md:py-24">
<div className="flex items-center justify-between border-b border-stone-200 pb-6 text-sm">
<span className="font-semibold tracking-widest">ENDOR / TABULARIUM</span>
<span className="rounded-full border border-stone-200 px-3 py-1 text-stone-500">本地文档工作台</span>
<span className="rounded-full border border-stone-200 px-3 py-1 text-stone-500">文档工作台</span>
</div>
<p className="mt-16 text-sm tracking-widest text-teal-800">文档 · 设计 · 图形</p>
<h1 className="mt-4 text-5xl font-semibold tracking-tight md:text-7xl">Endor 的知识档案。</h1>
+2 -2
View File
@@ -1,6 +1,6 @@
# 开始使用
Tabularium 是 Endor 的本地文档工作台。文档与图形源码都保存在同一个仓库。
Tabularium 是 Endor 的文档工作台。文档与图形源码都保存在同一个仓库。
## 启动
@@ -27,4 +27,4 @@ pnpm check
pnpm start
```
所有命令都在本地运行。Git 远程只负责源码同步,不承担构建或部署。
本地开发入口只监听 127.0.0.1。main 推送会在 ces-control 上验证并自动部署,服务器入口默认 18132。相同构建输入复用已验证镜像;源码变化时复用依赖与 Next.js 缓存。Pull Request 只验证,不部署。
+2
View File
@@ -14,4 +14,6 @@ pnpm figures
在 `tools/figures/src/endor_figures` 下保留各张图的生成逻辑,并在 CLI 中调用。输出目录通过源码位置计算,不依赖运行命令时所在的目录。
CI 会重新生成图片并检查与提交版本一致。Python 依赖保留独立缓存,不进入生产运行镜像。
图形应使用本地字体与资源,并明确标注草案状态。未来可按具体需求加入自动布局、PNG 转换或 TypeScript SVG 工具。
+18
View File
@@ -0,0 +1,18 @@
name: endorphin-tabularium-docs
services:
docs:
image: ${TABULARIUM_IMAGE:?verified image required}
init: true
ports:
- "${TABULARIUM_BIND:-0.0.0.0}:${TABULARIUM_PORT:-18132}:3000"
restart: unless-stopped
stop_grace_period: 20s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
pnpm install --frozen-lockfile --store-dir /pnpm/store
uv sync --project tools/figures --locked
pnpm run lint
pnpm run typecheck
cp -R apps/docs/public/figures /tmp/expected-figures
pnpm run figures
# Generated assets must be committed alongside their scripts.
diff -r /tmp/expected-figures apps/docs/public/figures
pnpm run build
+105
View File
@@ -0,0 +1,105 @@
"""Build on ces-control; reuse only verified images with identical build inputs."""
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
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"]
def run(*args: str, capture: bool = False) -> str:
result = subprocess.run(args, cwd=ROOT, text=True, check=True,
stdout=subprocess.PIPE if capture else None)
return result.stdout.strip() if capture else ""
def fingerprint() -> str:
files = run("git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", *INPUTS, capture=True)
digest = hashlib.sha256()
for name in sorted(set(files.split("\0"))):
if not name:
continue
data = (ROOT / name).read_bytes()
digest.update(name.encode() + b"\0" + str(len(data)).encode() + b"\0" + data)
return digest.hexdigest()
def smoke_image(image: str, name: str) -> None:
from smoke import verify
run("docker", "run", "-d", "--name", name, "-p", "127.0.0.1::3000", image)
try:
port = run("docker", "port", name, "3000/tcp", capture=True).rsplit(":", 1)[1]
for _ in range(60):
state = json.loads(run("docker", "inspect", name, capture=True))[0]["State"]
if state.get("Health", {}).get("Status") == "healthy":
verify(f"http://127.0.0.1:{port}")
return
if not state["Running"]:
break
time.sleep(1)
run("docker", "logs", "--tail", "80", name)
raise RuntimeError("Production image did not become healthy")
finally:
run("docker", "rm", "-f", name)
def main() -> None:
os.chdir(ROOT)
run(sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py")
scope = os.environ.get("CI_CACHE_SCOPE", "main")
if not re.fullmatch(r"main|pr-[0-9]+", scope):
raise ValueError("Unsupported cache scope")
with open(f"/var/lock/{APP}-{scope}-build.lock", "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
key = fingerprint()
verified = f"{APP}:verified-{scope}-{key}"
result = subprocess.run(["docker", "image", "inspect", verified],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
smoke_name = f"tabularium-smoke-{scope}-{os.getpid()}"
if result.returncode == 0:
print(f"CACHE HIT: reuse {verified}; no dependency install or next build", flush=True)
smoke_image(verified, smoke_name)
else:
print(f"CACHE MISS: {key}; dependency and Next.js caches remain persistent", flush=True)
checks = f"{APP}-checks:{scope}"
run("docker", "build", "-f", "Dockerfile.ci", "--target", "checks", "-t", checks, ".")
name = f"tabularium-checks-{scope}-{os.getpid()}"
output = ROOT / ".artifacts/runtime"
if output.exists():
shutil.rmtree(output)
output.mkdir(parents=True)
try:
run("docker", "run", "--name", name,
"--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",
checks)
run("docker", "cp", f"{name}:/workspace/apps/docs/.next/standalone/.", str(output))
app = output / "apps/docs"
(app / ".next").mkdir(parents=True, exist_ok=True)
run("docker", "cp", f"{name}:/workspace/apps/docs/.next/static", str(app / ".next/static"))
run("docker", "cp", f"{name}:/workspace/apps/docs/public", str(app / "public"))
candidate = f"{APP}:candidate-{scope}"
run("docker", "build", "-f", str(ROOT / "Dockerfile"),
"--label", f"io.entroia.tabularium.fingerprint={key}",
"-t", candidate, str(output))
smoke_image(candidate, smoke_name)
run("docker", "tag", candidate, verified)
finally:
subprocess.run(["docker", "rm", "-f", name], check=False)
(ROOT / ".artifacts").mkdir(exist_ok=True)
(ROOT / ".artifacts/verified-image").write_text(verified + "\n")
print(f"VERIFIED IMAGE: {verified}", flush=True)
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
"""Deploy a verified image, never rebuild during deployment or rollback."""
import fcntl
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import urllib.request
from ci import APP, ROOT, fingerprint, run
from smoke import verify
DEPLOY_ROOT = Path("/opt/endorphin-tabularium-docs")
CONFIG = Path("/etc/endorphin-tabularium-docs/production.env")
def latest_main() -> str:
base = os.environ["GITEA_SERVER_URL"].rstrip("/")
if base != "https://gitea.entroia.com":
raise ValueError("Unexpected Gitea API origin")
request = urllib.request.Request(base + "/api/v1/repos/Zayden-Jung/endorphin-tabularium-docs/branches/main",
headers={"Authorization": "token " + os.environ["GITEA_TOKEN"]})
with urllib.request.urlopen(request, timeout=25) as response:
return json.load(response)["commit"]["id"]
def compose(release: Path, *args: str) -> None:
run("docker", "compose", "-p", APP, "--env-file", str(CONFIG),
"--env-file", str(release / "release.env"), "-f", str(release / "compose.yml"), *args)
def main() -> None:
if os.geteuid() != 0:
raise RuntimeError("Deployment requires root on ces-control")
sha = os.environ["TABULARIUM_RELEASE_SHA"]
if not re.fullmatch(r"[0-9a-f]{40}", sha) or sha != run("git", "rev-parse", "HEAD", capture=True):
raise ValueError("Release SHA does not match checkout")
DEPLOY_ROOT.mkdir(parents=True, exist_ok=True)
with (DEPLOY_ROOT / ".deploy.lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
if latest_main() != sha:
print("Skipping stale release: main has advanced", flush=True)
return
expected = f"{APP}:verified-main-{fingerprint()}"
actual = (ROOT / ".artifacts/verified-image").read_text().strip()
if actual != expected:
raise RuntimeError("Verified image does not match current source")
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.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"))
if not 1024 <= port <= 65535:
raise ValueError("Invalid port")
current = DEPLOY_ROOT / "current"
previous = current.resolve() if current.is_symlink() else None
release = DEPLOY_ROOT / "releases" / sha
release.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / "docker-compose.ces-control.yml", release / "compose.yml")
(release / "release.env").write_text(f"TABULARIUM_IMAGE={actual}\n")
try:
compose(release, "up", "-d", "--no-build", "--wait", "--wait-timeout", "120")
verify(f"http://127.0.0.1:{port}")
except Exception:
if previous is not None:
print("Deployment failed; restoring previous verified release", flush=True)
compose(previous, "up", "-d", "--no-build", "--wait", "--wait-timeout", "120")
else:
compose(release, "stop", "docs")
raise
pending = DEPLOY_ROOT / "current.next"
if pending.is_symlink():
pending.unlink()
pending.symlink_to(release)
pending.replace(current)
compose(release, "ps")
print(f"Deployed release={sha} image={actual} port={port}", flush=True)
if __name__ == "__main__":
main()
+49
View File
@@ -0,0 +1,49 @@
"""Exercise actual production routes and assets without third-party dependencies."""
from html import unescape
import json
from pathlib import Path
import re
import sys
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[1]
def verify(base: str) -> None:
def get(path: str) -> tuple[bytes, str]:
with urllib.request.urlopen(base.rstrip("/") + path, timeout=15) as response:
if response.status != 200:
raise RuntimeError(f"Unexpected status for {path}: {response.status}")
return response.read(), response.headers.get("Content-Type", "")
health, _ = get("/api/health")
assert json.loads(health) == {"status": "ok"}
home, _ = get("/")
assert b"TABULARIUM" in home
for file in sorted((ROOT / "content/docs").glob("*.md")):
page, _ = get(f"/docs/{file.stem}")
title = re.search(r"^# (.+)$", file.read_text(), re.M)
assert title is not None
text = unescape(re.sub(r"<[^>]+>", "", page.decode()))
assert title.group(1) in text, file.name
assets = set(re.findall(r'(?:src|href)="(/_next/static/[^" ]+)"', home.decode()))
assert assets, "No generated JavaScript/CSS assets found"
for asset in assets:
get(unescape(asset))
for file in sorted((ROOT / "apps/docs/public/figures").glob("*.svg")):
data, kind = get(f"/figures/{file.name}")
assert "image/svg+xml" in kind
assert ET.fromstring(data).tag.endswith("svg")
try:
get("/docs/this-document-does-not-exist")
except urllib.error.HTTPError as error:
assert error.code == 404
else:
raise RuntimeError("Unknown document did not return 404")
print(f"Smoke passed: {base} (health, documents, JS/CSS, SVG, 404)", flush=True)
if __name__ == "__main__":
verify(sys.argv[1])
+39
View File
@@ -0,0 +1,39 @@
"""Guard cache invalidation against stale documents and needless metadata rebuilds."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import ci
class FingerprintTests(unittest.TestCase):
def test_build_inputs_invalidate_but_metadata_and_caches_do_not(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
subprocess.run(["git", "init", "-q", str(root)], check=True)
(root / ".gitignore").write_text(".artifacts/\n**/.next/\n")
doc = root / "content/docs/concept.md"
doc.parent.mkdir(parents=True)
doc.write_text("# Original\n")
package = root / "package.json"
package.write_text('{"private":true}\n')
with patch.object(ci, "ROOT", root):
original = ci.fingerprint()
(root / "README.md").write_text("metadata only\n")
cache = root / "apps/docs/.next/cache/state"
cache.parent.mkdir(parents=True)
cache.write_text("compiler cache\n")
self.assertEqual(original, ci.fingerprint())
doc.write_text("# Updated\n")
self.assertNotEqual(original, ci.fingerprint())
doc.write_text("# Original\n")
package.write_text('{"private":true,"version":"2"}\n')
self.assertNotEqual(original, ci.fingerprint())
package.write_text('{"private":true}\n')
doc.rename(doc.with_name("renamed.md"))
self.assertNotEqual(original, ci.fingerprint())
if __name__ == "__main__":
unittest.main()