diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 8fb70fd..1a9d80e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -34,3 +34,6 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 + + - name: Live probe published site + run: scripts/probe_site.sh diff --git a/README.md b/README.md index c120b63..22da778 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ This repo is **English-primary, zh-en bilingual**: source files are English; ext ## 包 -- `packages/site-board/`(flat):站点数据源 v0。cron 轮询 `FKST_GITHUB_REPO` 的 open issue/PR 板面,构建 `fkst-website.board.v1` 快照 JSON;`FKST_SITE_WRITE=1` 且设置 `FKST_SITE_PUBLISH_ROOT` 时原子发布 `board.json`(写 tmp + mv),否则 dry-run 只记日志。 +- `packages/site-board/`(flat):站点数据源 v0。cron 轮询 `FKST_GITHUB_REPO` 的 open issue/PR 板面,构建 `fkst-website.board.v1` 快照 JSON;`FKST_SITE_WRITE=1` 且设置 `FKST_SITE_PUBLISH_ROOT` 时原子发布 `board.json`(写 tmp + mv),否则 dry-run 只记日志。GitHub Pages deploy 后读取 `site/probe-manifest` 对发布站点做只读 live probe,只输出可 grep 的 `PROBE` ok/fail/skip 日志。 ## 构建 / 测试 diff --git a/scripts/probe_site.sh b/scripts/probe_site.sh new file mode 100755 index 0000000..d9df2ff --- /dev/null +++ b/scripts/probe_site.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Probe the deployed GitHub Pages site from the tracked manifest. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MANIFEST="${FKST_SITE_PROBE_MANIFEST:-$ROOT/site/probe-manifest}" +BASE_URL="${FKST_SITE_PROBE_BASE_URL:-https://chronoaiproject.github.io/fkst-website}" +TIMEOUT_SECONDS="${FKST_SITE_PROBE_TIMEOUT_SECONDS:-15}" +CONNECT_TIMEOUT_SECONDS="${FKST_SITE_PROBE_CONNECT_TIMEOUT_SECONDS:-5}" + +checked=0 +failures=0 +network_errors=0 +results=() + +while IFS= read -r path || [ -n "$path" ]; do + path="${path%%#*}" + path="${path#"${path%%[![:space:]]*}"}" + path="${path%"${path##*[![:space:]]}"}" + [ -n "$path" ] || continue + + checked=$((checked + 1)) + url="${BASE_URL}${path}" + code="$(curl -sL -o /dev/null -w '%{http_code}' --connect-timeout "$CONNECT_TIMEOUT_SECONDS" --max-time "$TIMEOUT_SECONDS" -- "$url")" || code="error" + [ -n "$code" ] || code="error" + results+=("${path}:${code}") + + if [ "$code" = "200" ]; then + continue + elif [ "$code" = "error" ]; then + network_errors=$((network_errors + 1)) + else + failures=$((failures + 1)) + fi +done < "$MANIFEST" + +joined="$(IFS=,; echo "${results[*]}")" +if [ "$checked" -eq 0 ]; then + echo "fkst-website dept=pages tag=skip PROBE paths=0 reason=empty-manifest results=" +elif [ "$network_errors" -eq "$checked" ]; then + echo "fkst-website dept=pages tag=skip PROBE paths=$checked reason=all-paths-network-error results=$joined" +elif [ "$failures" -gt 0 ] || [ "$network_errors" -gt 0 ]; then + echo "fkst-website dept=pages tag=fail PROBE paths=$checked failures=$failures network_errors=$network_errors results=$joined" + exit 1 +else + echo "fkst-website dept=pages tag=ok PROBE paths=$checked results=$joined" +fi diff --git a/scripts/probe_site_test.py b/scripts/probe_site_test.py new file mode 100755 index 0000000..0a9834c --- /dev/null +++ b/scripts/probe_site_test.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Unit tests for the deployed-site probe script.""" + +from __future__ import annotations + +import os +import stat +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PROBE = ROOT / "scripts" / "probe_site.sh" + + +class ProbeSiteTest(unittest.TestCase): + def run_probe(self, manifest: str, fake_curl: str) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + manifest_path = tmp_path / "probe-manifest" + manifest_path.write_text(manifest, encoding="utf-8") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + curl_path = bin_dir / "curl" + curl_path.write_text(fake_curl, encoding="utf-8") + curl_path.chmod(curl_path.stat().st_mode | stat.S_IXUSR) + + env = os.environ.copy() + env.update( + { + "PATH": f"{bin_dir}{os.pathsep}{env['PATH']}", + "FKST_SITE_PROBE_MANIFEST": str(manifest_path), + "FKST_SITE_PROBE_BASE_URL": "https://example.test", + } + ) + return subprocess.run( + [str(PROBE)], + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + def test_all_200_logs_ok(self) -> None: + curl = textwrap.dedent( + """\ + #!/usr/bin/env bash + printf '200' + """ + ) + result = self.run_probe("/\n/zh/\n", curl) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout.strip(), + "fkst-website dept=pages tag=ok PROBE paths=2 results=/:200,/zh/:200", + ) + + def test_partial_failure_logs_fail_and_exits_1(self) -> None: + curl = textwrap.dedent( + """\ + #!/usr/bin/env bash + url="${@: -1}" + case "$url" in + */missing.html) printf '404' ;; + *) printf '200' ;; + esac + """ + ) + result = self.run_probe("/\n/missing.html\n", curl) + + self.assertEqual(result.returncode, 1) + self.assertEqual( + result.stdout.strip(), + "fkst-website dept=pages tag=fail PROBE paths=2 failures=1 network_errors=0 results=/:200,/missing.html:404", + ) + + def test_all_network_error_logs_skip(self) -> None: + curl = textwrap.dedent( + """\ + #!/usr/bin/env bash + exit 7 + """ + ) + result = self.run_probe("/\n/zh/\n", curl) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout.strip(), + "fkst-website dept=pages tag=skip PROBE paths=2 reason=all-paths-network-error results=/:error,/zh/:error", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/run.sh b/scripts/run.sh index cc27be2..1e3ca7c 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -124,6 +124,7 @@ usage() { cmd_check() { python3 "$ROOT/scripts/check_repo.py" python3 "$ROOT/scripts/check_repo_test.py" + python3 "$ROOT/scripts/probe_site_test.py" } check_test_file_coverage() { diff --git a/site/probe-manifest b/site/probe-manifest new file mode 100644 index 0000000..fa9ae07 --- /dev/null +++ b/site/probe-manifest @@ -0,0 +1,6 @@ +/ +/zh/ +/architecture.html +/doctrine.html +/zh/architecture.html +/zh/doctrine.html