diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7da7097 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +**/node_modules +**/.next +**/.venv +**/__pycache__ +**/*.pyc +**/*.tsbuildinfo +.artifacts +.env* diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml new file mode 100644 index 0000000..3b252a4 --- /dev/null +++ b/.gitea/workflows/ci-deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index 1c46c5d..53e450f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ __pycache__/ .DS_Store .env* !.env.example + +.artifacts/ diff --git a/AGENTS.md b/AGENTS.md index 8515853..ee2068a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 。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`。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4e75403 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 0000000..2bb8c07 --- /dev/null +++ b/Dockerfile.ci @@ -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"] diff --git a/README.md b/README.md index 65edf4f..402c06a 100644 --- a/README.md +++ b/README.md @@ -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/.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--<摘要>` 镜像时跳过依赖安装和 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/`,`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 内置任务令牌,不保存检出凭据。 diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index cd967eb..7762d94 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -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, }; diff --git a/apps/docs/src/pages/api/health.ts b/apps/docs/src/pages/api/health.ts new file mode 100644 index 0000000..51feec8 --- /dev/null +++ b/apps/docs/src/pages/api/health.ts @@ -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" }); +} diff --git a/apps/docs/src/pages/index.tsx b/apps/docs/src/pages/index.tsx index 288ad62..768dc8b 100644 --- a/apps/docs/src/pages/index.tsx +++ b/apps/docs/src/pages/index.tsx @@ -10,11 +10,11 @@ export const getStaticProps: GetStaticProps<{ docs: DocLink[] }> = async () => ( export default function Home({ docs }: InferGetStaticPropsType) { return ( <> - Tabularium · Endor 文档 + Tabularium · Endor 文档
ENDOR / TABULARIUM - 本地文档工作台 + 文档工作台

文档 · 设计 · 图形

Endor 的知识档案。

diff --git a/content/docs/01-getting-started.md b/content/docs/01-getting-started.md index 4d54e2e..4e48284 100644 --- a/content/docs/01-getting-started.md +++ b/content/docs/01-getting-started.md @@ -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 只验证,不部署。 diff --git a/content/docs/03-figures.md b/content/docs/03-figures.md index 452b32b..fccbd00 100644 --- a/content/docs/03-figures.md +++ b/content/docs/03-figures.md @@ -14,4 +14,6 @@ pnpm figures 在 `tools/figures/src/endor_figures` 下保留各张图的生成逻辑,并在 CLI 中调用。输出目录通过源码位置计算,不依赖运行命令时所在的目录。 +CI 会重新生成图片并检查与提交版本一致。Python 依赖保留独立缓存,不进入生产运行镜像。 + 图形应使用本地字体与资源,并明确标注草案状态。未来可按具体需求加入自动布局、PNG 转换或 TypeScript SVG 工具。 diff --git a/docker-compose.ces-control.yml b/docker-compose.ces-control.yml new file mode 100644 index 0000000..18569d1 --- /dev/null +++ b/docker-compose.ces-control.yml @@ -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" diff --git a/scripts/check-container.sh b/scripts/check-container.sh new file mode 100644 index 0000000..f0855a7 --- /dev/null +++ b/scripts/check-container.sh @@ -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 diff --git a/scripts/ci.py b/scripts/ci.py new file mode 100644 index 0000000..912f137 --- /dev/null +++ b/scripts/ci.py @@ -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() diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100644 index 0000000..15f44e8 --- /dev/null +++ b/scripts/deploy.py @@ -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() diff --git a/scripts/smoke.py b/scripts/smoke.py new file mode 100644 index 0000000..ee324af --- /dev/null +++ b/scripts/smoke.py @@ -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]) diff --git a/scripts/test_ci.py b/scripts/test_ci.py new file mode 100644 index 0000000..37c1328 --- /dev/null +++ b/scripts/test_ci.py @@ -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()