fix(ci): harden publishing and validate examples

This commit is contained in:
yeasy
2026-07-10 19:29:30 -07:00
parent 8eabe30dc2
commit daa6661b2b
26 changed files with 27456 additions and 171 deletions
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Validate the book's canonical container examples with their native tools."""
import argparse
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
ROOT = Path(__file__).resolve().parents[1]
FIXTURE_ROOT = ROOT / "examples" / "validated"
@dataclass(frozen=True)
class Check:
name: str
executable: str
command: List[str]
probe: Optional[List[str]] = None
CHECKS = (
Check(
"docker compose",
"docker",
[
"docker",
"compose",
"-f",
str(FIXTURE_ROOT / "compose" / "compose.yaml"),
"config",
"--quiet",
],
["docker", "compose", "version"],
),
Check(
"docker buildx",
"docker",
[
"docker",
"buildx",
"build",
"--check",
"--file",
str(FIXTURE_ROOT / "dockerfile" / "Dockerfile"),
str(FIXTURE_ROOT / "dockerfile"),
],
["docker", "buildx", "version"],
),
Check(
"kubeconform",
"kubeconform",
[
"kubeconform",
"-strict",
"-summary",
"-kubernetes-version",
"1.31.0",
str(FIXTURE_ROOT / "kubernetes" / "web.yaml"),
],
),
Check(
"actionlint",
"actionlint",
["actionlint", str(FIXTURE_ROOT / "github-actions" / "validate.yml")],
),
)
def run_command(command):
return subprocess.run(command, cwd=ROOT, capture_output=True, text=True)
def unavailable(check, require_tools, detail):
status = "UNAVAILABLE" if require_tools else "SKIP"
print(f"[{status}] {check.name}: {detail}")
return require_tools
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--require-tools",
action="store_true",
help="fail instead of skipping when a validator is unavailable (for CI)",
)
args = parser.parse_args()
failed = False
for check in CHECKS:
if shutil.which(check.executable) is None:
failed = unavailable(check, args.require_tools, f"{check.executable} not found") or failed
continue
if check.probe is not None:
probe = run_command(check.probe)
if probe.returncode != 0:
detail = (probe.stderr or probe.stdout or "plugin probe failed").strip()
failed = unavailable(check, args.require_tools, detail) or failed
continue
result = run_command(check.command)
if result.returncode != 0:
print(f"[FAIL] {check.name}")
print((result.stderr or result.stdout).strip())
failed = True
continue
print(f"[PASS] {check.name}")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
+16 -2
View File
@@ -2,12 +2,24 @@ import tempfile
import unittest
from base64 import b64encode
from pathlib import Path
from tools.prepare_pdf_sources import normalize_markdown_asset_paths, prepare_pdf_sources
import subprocess
import sys
class PreparePdfSourcesTest(unittest.TestCase):
def test_module_imports_with_running_python(self):
result = subprocess.run(
[sys.executable, "-c", "import tools.prepare_pdf_sources"],
cwd=Path(__file__).resolve().parents[1],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_normalizes_relative_markdown_and_html_image_paths(self):
from tools.prepare_pdf_sources import normalize_markdown_asset_paths
with tempfile.TemporaryDirectory() as tmp:
book_dir = Path(tmp)
(book_dir / "_images").mkdir()
@@ -31,6 +43,8 @@ class PreparePdfSourcesTest(unittest.TestCase):
self.assertEqual(count, 2)
def test_prepares_temp_tree_without_mutating_source_markdown(self):
from tools.prepare_pdf_sources import prepare_pdf_sources
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "source"
target = Path(tmp) / "target"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Smoke-test built book artifacts and write a portable SHA-256 manifest."""
import argparse
import hashlib
import html
import re
import shutil
import subprocess
import sys
from pathlib import Path
def fail(message):
print(f"artifact verification failed: {message}", file=sys.stderr)
raise SystemExit(1)
def normalized_title(value):
return " ".join(html.unescape(value).split())
def require_file(path):
if not path.is_file():
fail(f"{path} does not exist or is not a file")
if path.stat().st_size == 0:
fail(f"{path} is empty")
def verify_html(path, expected_title):
require_file(path)
text = path.read_text(encoding="utf-8")
match = re.search(r"<title(?:\s[^>]*)?>(.*?)</title>", text, re.IGNORECASE | re.DOTALL)
actual = normalized_title(match.group(1)) if match else ""
expected = normalized_title(expected_title)
accepted = actual == expected or actual.startswith(f"{expected} - ") or actual.endswith(f" - {expected}")
if not accepted:
fail(f"{path} title mismatch: expected {expected_title!r}, got {actual!r}")
def command_output(command):
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
fail(f"command failed ({' '.join(command)}): {(result.stderr or result.stdout).strip()}")
return result.stdout
def verify_pdf(path, expected_title):
require_file(path)
if not path.read_bytes().startswith(b"%PDF-"):
fail(f"{path} does not have a PDF signature")
if shutil.which("pdfinfo") is None or shutil.which("pdftotext") is None:
fail("pdfinfo and pdftotext are required for PDF title verification")
metadata = command_output(["pdfinfo", str(path)])
title_match = re.search(r"(?m)^Title:\s*(.*)$", metadata)
metadata_title = normalized_title(title_match.group(1)) if title_match else ""
expected = normalized_title(expected_title)
if metadata_title == expected:
return
first_pages = normalized_title(
command_output(["pdftotext", "-f", "1", "-l", "2", str(path), "-"])
)
if expected not in first_pages:
fail(
f"{path} title mismatch: expected {expected_title!r}; "
f"PDF metadata title was {metadata_title!r}"
)
def write_checksums(paths, destination):
destination.parent.mkdir(parents=True, exist_ok=True)
lines = []
for path in sorted(paths, key=lambda item: item.name):
if path.parent.resolve() != destination.parent.resolve():
fail(f"{path} must be beside checksum manifest {destination}")
digest = hashlib.sha256(path.read_bytes()).hexdigest()
lines.append(f"{digest} {path.name}\n")
destination.write_text("".join(lines), encoding="utf-8")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--title", required=True)
parser.add_argument("--pdf", type=Path)
parser.add_argument("--html", type=Path)
parser.add_argument("--site", type=Path)
parser.add_argument("--checksums", type=Path)
args = parser.parse_args()
artifacts = []
if args.pdf:
verify_pdf(args.pdf, args.title)
artifacts.append(args.pdf)
if args.html:
verify_html(args.html, args.title)
artifacts.append(args.html)
if args.site:
verify_html(args.site / "index.html", args.title)
if not artifacts and not args.site:
parser.error("at least one of --pdf, --html, or --site is required")
if args.checksums:
if not artifacts:
fail("a checksum manifest requires at least one file artifact")
write_checksums(artifacts, args.checksums)
for path in artifacts:
print(f"verified artifact: {path}")
if args.site:
print(f"verified site: {args.site / 'index.html'}")
if args.checksums:
print(f"wrote checksums: {args.checksums}")
return 0
if __name__ == "__main__":
raise SystemExit(main())