添加文档自动检查、Actions 工作流与 PR 维护模板
docs / check (push) Failing after 0s

This commit is contained in:
2026-09-16 18:50:57 +00:00
parent cf4430526a
commit e394b2612d
9 changed files with 399 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
## 改了什么
说明具体问题、修改后的使用方法或事实;纯文字修正可简写。
## 依据
关联源码 commit/PR、ticket 或带日期的维护者说明。若来自未提交工作区,明确标注。
## 验证与范围
填写本地检查/CI 结果。现场是否查询、验证了什么、哪些示例未执行?
没有现场验证时,不因编辑文字而刷新 last_verified。
## 文档同步
- [ ] 涉及的服务页已更新;新增/退役服务已同步服务总览。
- [ ] 入口或设计变化已更新相关任务导航/架构约束(不适用可注明)。
- [ ] 已注明来源与尚未完成的部分,未包含实际凭据。
原仓库 README 同步目前暂缓,不是本 PR 的必做项。
+26
View File
@@ -0,0 +1,26 @@
name: docs
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
check:
runs-on: [self-hosted, pod]
container:
image: python:3.12-bookworm
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install checker dependencies
run: python3 -m pip install --disable-pip-version-check -r requirements-docs.txt
- name: Test checker
run: python3 -m unittest discover -s tests -v
- name: Check documentation
run: python3 scripts/check_docs.py
- name: Check whitespace
run: git show --format= --check HEAD
+3
View File
@@ -6,3 +6,6 @@ node_modules/
.env
.env.*
!.env.example
.venv/
__pycache__/
*.pyc
+1
View File
@@ -19,4 +19,5 @@
- 每项当前事实注明来源;last_verified 只在完成所述现场验证后更新,不随文字编辑刷新。
- 凭据只记录取得方式和受管位置,不复制实际密码、token、私钥、state 或含敏感值的输出。
- 不复制 apps/tailscale/helm.sh 的内容。迁移旧文档前先审查敏感内容,不能整库直接发布。
- 提交前运行 `python3 scripts/check_docs.py`;修改检查器时运行 `python3 -m unittest discover -s tests -v`。依赖与本地环境见 CONTRIBUTING.md。
- 遵守 CONTRIBUTING.md;不把临时检查日志和个人 agent memory 当成正式文档。
+50
View File
@@ -52,3 +52,53 @@ accepted 不代表部署完成,implemented 必须附实现和验收依据。
使用普通 Markdown 链接、相对附件路径和文字说明;关键事实直接写入正文。
可选 Obsidian 编辑,个人布局、缓存、插件和同步配置不提交。对外分享前检查敏感内容。
提交前检查相对链接、服务目录覆盖,以及新增内容是否混淆计划和运行事实。
## 一次服务变更应更新哪里
| 变化 | 必须查看的文档 |
|---|---|
| 使用入口、认证、权限、客户端参数 | 对应 `services/` 页面;入口变化同时更新服务总览 |
| 新增或退役组件 | 服务页、`services/index.md`;任务入口变化再改 `guides/task-index.md` |
| 跨服务设计或边界 | `architecture/constraints.md` 及受影响指南 |
| 只有开发进度变化 | 原项目 ticket;wiki 仅在阶段摘要需要变化时更新并注明日期 |
| 取得新的验证结果 | 服务页说明日期与验证范围,据实更新 `last_verified` |
| 工作区来源已合并 | 核对实际内容后更新 `sources.md` 的固定链接及差异标记 |
先修改最接近事实的页面,再同步导航,避免把同一套操作复制到多份文档。
无需每次修改都更新首页、所有服务页或整个来源索引。
原仓库 README 同步按维护者要求暂缓,不阻塞 wiki 的维护。
PR 使用 [.gitea/PULL_REQUEST_TEMPLATE.md](.gitea/PULL_REQUEST_TEMPLATE.md),简述问题、最终变化、依据与验证。
直接提交也遵循相同的检查与证据规则,不为纯文案修改制造额外审批。
## 本地与 CI 检查
需要 Python 3.10 或更新版本;首次在 wiki 根目录准备环境:
```bash
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements-docs.txt
.venv/bin/python -m unittest discover -s tests -v
.venv/bin/python scripts/check_docs.py
git diff --check
```
依赖版本固定在 [requirements-docs.txt](requirements-docs.txt)。安装依赖需要网络,检查器本身离线运行。
Gitea 工作流 [.gitea/workflows/docs.yml](.gitea/workflows/docs.yml) 在 main push、PR 和手动触发时运行,
使用 `[self-hosted, pod]` 与 Python 容器;无需业务秘密或集群权限。
CI 拉取容器、checkout action 和依赖仍需要对应网络可用。
检查范围:
- Markdown 的相对文件链接、图片、引用式链接及本地标题锚点;不探测远端 URL。
- frontmatter 的类型、重复键、状态枚举、日期与 `live-verified` 必须有验证日期的约束。
- `services/` 下的服务页必须有完整状态字段,并由服务总览链接;总览及外部消费者范围页除外。
- 已有 frontmatter 的其他页面校验 title 和审阅日期;`templates/` 允许日期占位为 null。
标题锚点按常见 Gitea/GitHub 规则处理中文、字母、数字、连字符和重复标题。
需要特殊字符锚点时可声明 HTML `id`,不要依赖 Obsidian 插件或非标准 heading 属性。
代码块和行内代码里的路径是示例或说明,不当链接执行或检查;源码路径的存在性由 `sources.md` 的明确核对维护。
检查错误带文件与行号,但不打印原始 frontmatter 内容。
检查器不证明命令正确、外链可达、事实最新或服务健康,也不自动获取凭据或执行文档中的示例。
+6
View File
@@ -38,6 +38,12 @@ LiteLLM、Tailscale、ps3netsrv、vlmcsd 与 k3s 的 wiki 指南已补;源码
- 已核对 62 个主要来源文件,提供[固定版本与工作区差异](sources.md)。
- 仍有差异或未收录的来源,等内容合并后再补对应版本;不自动提交原仓库工作区。
## 持续维护
- 已提供离线文档检查器、Gitea Actions 工作流与检查器测试。
- 已补 PR 模板及服务变更到文档的对应规则,见 [CONTRIBUTING](CONTRIBUTING.md)。
- Backstage 展示与 OpenViking 自动索引仍是独立后续工作,本轮未接入。
## 原仓库文档同步(按维护者要求暂缓)
- 源码仓库中 Authelia OIDC、LAN DNS、SPIRE 的旧说明后续与知识库结论同步。
+3
View File
@@ -0,0 +1,3 @@
markdown-it-py==3.0.0
mdurl==0.1.2
PyYAML==6.0.1
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""离线检查 wiki 的元数据、链接、标题锚点与服务索引。"""
from __future__ import annotations
import argparse
from datetime import date
from html.parser import HTMLParser
from pathlib import Path
import re
import subprocess
import sys
import unicodedata
from urllib.parse import unquote, urlsplit
from markdown_it import MarkdownIt
import yaml
LIFECYCLES = {"planned", "experimental", "active", "retired", "unknown"}
EVIDENCE = {"configuration", "documented", "live-verified"}
# These two pages are navigation/scope descriptions, not individual services.
SERVICE_INDEXES = {"services/index.md", "services/external-consumers.md"}
class UniqueLoader(yaml.SafeLoader):
pass
def unique_mapping(loader, node):
result = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node)
if not isinstance(key, str):
raise ValueError("frontmatter 键必须是字符串")
if key in result:
raise ValueError(f"重复 frontmatter 键:{key}")
result[key] = loader.construct_object(value_node)
return result
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping)
def split_frontmatter(text):
lines = text.splitlines(keepends=True)
if not lines or lines[0].strip() != "---":
return None, text
for i in range(1, len(lines)):
if lines[i].strip() == "---":
metadata = yaml.load("".join(lines[1:i]), Loader=UniqueLoader)
if not isinstance(metadata, dict):
raise ValueError("frontmatter 必须是映射")
# Preserve line positions for diagnostics.
return metadata, "\n" * (i + 1) + "".join(lines[i + 1:])
raise ValueError("frontmatter 缺少结束分隔符")
def as_date(value):
if type(value) is date:
return value
if isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
return date.fromisoformat(value)
raise ValueError("必须是 YYYY-MM-DD 日期")
def metadata_errors(meta, required=False, template=False):
if meta is None:
return ["服务页缺少 frontmatter"] if required else []
errors = []
keys = {"title", "last_reviewed"}
if required:
keys |= {"lifecycle", "evidence", "last_verified"}
for key in sorted(keys - meta.keys()):
errors.append(f"缺少字段 {key}")
if not isinstance(meta.get("title"), str) or not meta["title"].strip():
errors.append("title 必须是非空字符串")
for key, choices in [("lifecycle", LIFECYCLES), ("evidence", EVIDENCE)]:
if key in meta and (not isinstance(meta[key], str) or meta[key] not in choices):
errors.append(f"{key} 不在允许值中")
dates = {}
for key in ["last_reviewed", "last_verified"]:
if key not in meta:
continue
if meta[key] is None:
if key == "last_reviewed" and not template:
errors.append("last_reviewed 不得为 null")
continue
try:
dates[key] = as_date(meta[key])
except (ValueError, TypeError):
errors.append(f"{key} 必须是 YYYY-MM-DD 日期或允许的 null")
if meta.get("evidence") == "live-verified" and "last_verified" not in dates:
errors.append("live-verified 必须提供 last_verified 日期")
if len(dates) == 2 and dates["last_verified"] > dates["last_reviewed"]:
errors.append("last_verified 不能晚于 last_reviewed")
if "sources" in meta and (not isinstance(meta["sources"], list) or
any(not isinstance(x, str) or not x.strip() for x in meta["sources"])):
errors.append("sources 必须是非空字符串组成的列表(可为空列表)")
return errors
def slug(text):
# Common Gitea/GitHub heading form; keep CJK, words, spaces and hyphens.
return "".join(c for c in text.lower() if c in " -_" or
unicodedata.category(c)[0] in "LN").replace(" ", "-")
class HTMLLinks(HTMLParser):
def __init__(self):
super().__init__()
self.links = []
self.anchors = set()
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
for key in ["href", "src"]:
if attrs.get(key):
self.links.append(attrs[key])
if attrs.get("id"):
self.anchors.add(attrs["id"])
if tag == "a" and attrs.get("name"):
self.anchors.add(attrs["name"])
def parse_body(body):
tokens = MarkdownIt("commonmark").enable("table").parse(body)
anchors, links = set(), []
for i, token in enumerate(tokens):
if token.type == "heading_open":
inline = tokens[i + 1]
text = "".join(t.content for t in inline.children or []
if t.type in {"text", "code_inline", "image"})
base = slug(text)
candidate, suffix = base, 0
while candidate in anchors:
suffix += 1
candidate = f"{base}-{suffix}"
anchors.add(candidate)
def visit(t, line):
line = t.map[0] + 1 if t.map else line
if t.type in {"link_open", "image"}:
url = t.attrGet("href" if t.type == "link_open" else "src")
if url is not None:
links.append((line, url))
if t.type in {"html_inline", "html_block"}:
html = HTMLLinks()
html.feed(t.content)
anchors.update(html.anchors)
links.extend((line, u) for u in html.links)
for child in t.children or []:
visit(child, line)
visit(token, 1)
return anchors, links
def check(root, files):
root = root.resolve()
errors, documents = [], {}
for relative in files:
path = root / relative
try:
if not path.resolve().is_relative_to(root):
raise ValueError("文件指向仓库外部")
meta, body = split_frontmatter(path.read_text(encoding="utf-8"))
required = relative.startswith("services/") and relative not in SERVICE_INDEXES
errors.extend(f"{relative}:1: {e}" for e in metadata_errors(
meta, required=required, template=relative.startswith("templates/")))
documents[relative] = parse_body(body)
except (ValueError, OSError, yaml.YAMLError) as exc:
# Do not print YAML source lines: malformed frontmatter may contain secrets.
errors.append(f"{relative}:1: 无法解析文件或 frontmatter({type(exc).__name__})")
indexed = set()
for relative, (_, links) in documents.items():
for line, url in links:
prefix = f"{relative}:{line}: "
try:
parsed = urlsplit(url)
except ValueError:
errors.append(prefix + "URL 格式无效")
continue
if parsed.scheme in {"http", "https", "mailto", "tel", "data"} or parsed.netloc:
continue
if parsed.scheme or parsed.path.startswith("/"):
errors.append(prefix + "链接必须使用仓库内相对路径或网页 URL")
continue
target = ((root / relative).parent / unquote(parsed.path)).resolve() if parsed.path else root / relative
if not target.is_relative_to(root):
errors.append(prefix + "链接越出仓库")
continue
if not target.exists():
errors.append(prefix + f"目标不存在:{unquote(parsed.path)}")
continue
dest = target.relative_to(root).as_posix()
if relative == "services/index.md":
indexed.add(dest)
anchor = unquote(parsed.fragment)
if anchor and dest in documents and anchor not in documents[dest][0]:
errors.append(prefix + f"标题锚点不存在:{dest}#{anchor}")
for relative in documents:
if relative.startswith("services/") and relative not in SERVICE_INDEXES and relative not in indexed:
errors.append(f"{relative}:1: 服务页未被 services/index.md 链接")
return errors
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
args = parser.parse_args()
output = subprocess.check_output(
["git", "-C", str(args.root), "ls-files", "--cached", "--others", "--exclude-standard", "-z"])
files = sorted({p for p in output.decode().split("\0") if p.endswith(".md")})
errors = check(args.root, files)
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
print(f"文档检查通过:{len(files)} 个 Markdown 文件;未联网或执行文档示例。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+69
View File
@@ -0,0 +1,69 @@
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from check_docs import check, metadata_errors, split_frontmatter
class DocumentChecks(unittest.TestCase):
def run_check(self, documents):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for name, text in documents.items():
file = root / name
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(text)
return check(root, list(documents))
def test_commonmark_links_and_code_examples(self):
errors = self.run_check({
"README.md": "[引用][page]\n\n[page]: guide.md#中文-标题\n\n"
"![附件](asset.svg)\n\n`[假链接](missing.md)`\n\n"
"```md\n[示例](missing.md)\n```\n",
"guide.md": "# 中文 标题\n",
"asset.svg": "<svg/>",
})
self.assertEqual([], errors)
def test_broken_reference_and_heading(self):
errors = self.run_check({"README.md": "[x][r]\n\n[r]: missing.md\n\n[x](#absent)\n"})
self.assertTrue(any("目标不存在" in e for e in errors))
self.assertTrue(any("标题锚点不存在" in e for e in errors))
def test_duplicate_headings_and_html_anchor(self):
self.assertEqual([], self.run_check({"README.md":
'# Same\n# Same\n<a id="custom"></a>\n[x](#same-1) [y](#custom)\n'}))
def test_escaped_paths_tables_and_external_links(self):
self.assertEqual([], self.run_check({
"README.md": '| a | b |\n|---|---|\n| [x](a%20b.md) | [web](https://example.invalid) |\n',
"a b.md": '# Target\n',
}))
def test_outside_repository_rejected(self):
errors = self.run_check({"README.md": "[x](../outside.md)\n"})
self.assertTrue(any("越出仓库" in e for e in errors))
def test_frontmatter_dates_and_evidence(self):
meta, _ = split_frontmatter('---\ntitle: Example\nlifecycle: active\nevidence: live-verified\n'
'last_reviewed: 2026-09-16\nlast_verified: null\n---\n# Title\n')
self.assertTrue(any("live-verified" in e for e in metadata_errors(meta, required=True)))
meta['last_verified'] = '2026-09-17'
self.assertTrue(any("不能晚于" in e for e in metadata_errors(meta, required=True)))
meta['last_verified'] = '2026-02-30'
self.assertTrue(any("YYYY-MM-DD" in e for e in metadata_errors(meta, required=True)))
def test_duplicate_yaml_keys_rejected(self):
errors = self.run_check({"README.md": '---\ntitle: A\ntitle: B\n---\n'})
self.assertTrue(errors)
def test_new_service_requires_metadata_and_index(self):
errors = self.run_check({"services/new.md": "# New\n", "services/index.md": "# Services\n"})
self.assertTrue(any("缺少 frontmatter" in e for e in errors))
self.assertTrue(any("未被 services/index.md" in e for e in errors))
if __name__ == '__main__':
unittest.main()