Deploy Tabularium on ces-control with verified image reuse and persistent caches
Verify and deploy Tabularium / verify-and-deploy (push) Waiting to run
Verify and deploy Tabularium / verify-and-deploy (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/.venv
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/*.tsbuildinfo
|
||||
.artifacts
|
||||
.env*
|
||||
@@ -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
|
||||
@@ -9,3 +9,5 @@ __pycache__/
|
||||
.DS_Store
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
.artifacts/
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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 内置任务令牌,不保存检出凭据。
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 只验证,不部署。
|
||||
|
||||
@@ -14,4 +14,6 @@ pnpm figures
|
||||
|
||||
在 `tools/figures/src/endor_figures` 下保留各张图的生成逻辑,并在 CLI 中调用。输出目录通过源码位置计算,不依赖运行命令时所在的目录。
|
||||
|
||||
CI 会重新生成图片并检查与提交版本一致。Python 依赖保留独立缓存,不进入生产运行镜像。
|
||||
|
||||
图形应使用本地字体与资源,并明确标注草案状态。未来可按具体需求加入自动布局、PNG 转换或 TypeScript SVG 工具。
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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])
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user