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
+109
View File
@@ -0,0 +1,109 @@
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNNER = ROOT / "tools" / "test_examples.py"
FIXTURES = {
"compose": ROOT / "examples" / "validated" / "compose" / "compose.yaml",
"dockerfile": ROOT / "examples" / "validated" / "dockerfile" / "Dockerfile",
"kubernetes": ROOT / "examples" / "validated" / "kubernetes" / "web.yaml",
"github-actions": ROOT / "examples" / "validated" / "github-actions" / "validate.yml",
}
class ExampleValidationTests(unittest.TestCase):
def run_runner(self, *args, path=None, env=None):
run_env = os.environ.copy()
run_env.update(env or {})
if path is not None:
run_env["PATH"] = path
return subprocess.run(
[sys.executable, str(RUNNER), *args],
cwd=ROOT,
env=run_env,
capture_output=True,
text=True,
)
def test_canonical_fixture_files_exist(self):
missing = [name for name, path in FIXTURES.items() if not path.is_file()]
self.assertEqual(missing, [])
def test_relevant_chapters_link_to_canonical_fixtures(self):
references = {
ROOT / "11_compose" / "11.5_compose_file.md": "../examples/validated/compose/compose.yaml",
ROOT / "07_dockerfile" / "README.md": "../examples/validated/dockerfile/Dockerfile",
ROOT / "13_kubernetes_concepts" / "13.5_practice.md": "../examples/validated/kubernetes/web.yaml",
ROOT / "21_case_devops" / "21.2_github_actions.md": "../examples/validated/github-actions/validate.yml",
}
missing = []
for chapter, fixture in references.items():
if fixture not in chapter.read_text(encoding="utf-8"):
missing.append(f"{chapter.relative_to(ROOT)} -> {fixture}")
self.assertEqual(missing, [])
def test_github_actions_fixture_installs_integrity_pinned_validators(self):
text = FIXTURES["github-actions"].read_text(encoding="utf-8")
self.assertIn("KUBECONFORM_SHA256", text)
self.assertIn("ACTIONLINT_SHA256", text)
self.assertGreaterEqual(text.count("sha256sum -c -"), 2)
self.assertIn("tools/test_examples.py --require-tools", text)
def test_local_run_reports_skips_when_tools_are_unavailable(self):
result = self.run_runner(path="/usr/bin:/bin")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(result.stdout.count("SKIP"), 4, result.stdout)
self.assertIn("docker compose", result.stdout)
self.assertIn("docker buildx", result.stdout)
self.assertIn("kubeconform", result.stdout)
self.assertIn("actionlint", result.stdout)
def test_required_run_fails_when_tools_are_unavailable(self):
result = self.run_runner("--require-tools", path="/usr/bin:/bin")
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout.count("UNAVAILABLE"), 4, result.stdout)
def test_available_tools_receive_the_canonical_validation_commands(self):
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
log = tmp_path / "commands.log"
shim = tmp_path / "validator-shim"
shim.write_text(
"#!/bin/sh\n"
"printf '%s %s\\n' \"$(basename \"$0\")\" \"$*\" >> \"$COMMAND_LOG\"\n"
"exit 0\n",
encoding="utf-8",
)
shim.chmod(0o755)
for name in ("docker", "kubeconform", "actionlint"):
(tmp_path / name).symlink_to(shim)
result = self.run_runner(
"--require-tools",
path=f"{tmp_path}:/usr/bin:/bin",
env={"COMMAND_LOG": str(log)},
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
commands = log.read_text(encoding="utf-8")
self.assertIn("docker compose version", commands)
self.assertIn("docker compose -f", commands)
self.assertIn("config --quiet", commands)
self.assertIn("docker buildx version", commands)
self.assertIn("docker buildx build --check", commands)
self.assertIn("kubeconform -strict -summary", commands)
self.assertIn("actionlint", commands)
if __name__ == "__main__":
unittest.main()
+64
View File
@@ -0,0 +1,64 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = ROOT / ".github" / "workflows" / "pages.yml"
FULL_ACTION_SHA = re.compile(r"^[^@\s]+@[0-9a-f]{40}$")
class PagesWorkflowTests(unittest.TestCase):
def workflow_text(self):
self.assertTrue(WORKFLOW.is_file(), "custom Pages workflow is missing")
return WORKFLOW.read_text(encoding="utf-8")
def test_custom_pages_workflow_exists(self):
self.assertTrue(WORKFLOW.is_file(), "custom Pages workflow is missing")
def test_builds_mdpress_site_without_jekyll(self):
text = self.workflow_text()
self.assertIn("npm run build", text)
self.assertIn("MDPRESS_SHA256", text)
self.assertIn('install -m 0755 "$RUNNER_TEMP/mdpress" "$RUNNER_TEMP/bin/mdpress"', text)
self.assertIn('echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"', text)
self.assertRegex(text, r"path:\s*_site\b")
self.assertNotIn("jekyll", text.lower())
def test_build_and_deploy_jobs_have_minimum_permissions(self):
text = self.workflow_text()
self.assertRegex(
text,
r"(?ms)^ build:\n permissions:\n contents: read\n pages: read\b",
)
self.assertRegex(
text,
r"(?ms)^ deploy:.*?permissions:\n pages: write\n id-token: write\b",
)
self.assertRegex(text, r"(?ms)^ deploy:.*?needs: build\b")
self.assertIn("environment:", text)
self.assertIn("name: github-pages", text)
def test_actions_are_immutable_and_checkout_drops_credentials(self):
text = self.workflow_text()
actions = re.findall(r"\buses:\s*([^\s#]+)", text)
self.assertGreater(len(actions), 0)
self.assertTrue(all(FULL_ACTION_SHA.fullmatch(action) for action in actions), actions)
self.assertRegex(text, r"actions/checkout@[0-9a-f]{40}\s+# v\d")
self.assertRegex(
text,
r"(?ms)actions/checkout@[0-9a-f]{40}.*?with:\n\s+persist-credentials: false",
)
def test_documents_manual_pages_source_setting(self):
text = self.workflow_text()
self.assertIn("Settings > Pages > Source", text)
self.assertIn("GitHub Actions", text)
if __name__ == "__main__":
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
import hashlib
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VERIFIER = ROOT / "tools" / "verify_artifacts.py"
class VerifyArtifactsTests(unittest.TestCase):
def run_verifier(self, *args):
return subprocess.run(
[sys.executable, str(VERIFIER), *args],
cwd=ROOT,
capture_output=True,
text=True,
)
def test_verifies_html_title_and_writes_sha256_manifest(self):
with tempfile.TemporaryDirectory() as tmp:
artifact = Path(tmp) / "reader.html"
checksums = Path(tmp) / "SHA256SUMS"
artifact.write_text(
"<!doctype html><html><head><title>Docker —— 从入门到实践</title></head></html>",
encoding="utf-8",
)
result = self.run_verifier(
"--title",
"Docker —— 从入门到实践",
"--html",
str(artifact),
"--checksums",
str(checksums),
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
expected = hashlib.sha256(artifact.read_bytes()).hexdigest()
self.assertEqual(checksums.read_text(encoding="utf-8"), f"{expected} reader.html\n")
def test_rejects_missing_artifact(self):
result = self.run_verifier(
"--title",
"Docker —— 从入门到实践",
"--html",
"/does/not/exist.html",
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("does not exist", result.stderr)
def test_rejects_wrong_html_title(self):
with tempfile.TemporaryDirectory() as tmp:
artifact = Path(tmp) / "reader.html"
artifact.write_text("<title>Wrong book</title>", encoding="utf-8")
result = self.run_verifier(
"--title",
"Docker —— 从入门到实践",
"--html",
str(artifact),
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("title mismatch", result.stderr)
def test_accepts_mdpress_site_title_with_page_suffix(self):
with tempfile.TemporaryDirectory() as tmp:
site = Path(tmp) / "_site"
site.mkdir()
(site / "index.html").write_text(
"<title>Docker 从入门到实践 - Docker 从入门到实践</title>",
encoding="utf-8",
)
result = self.run_verifier(
"--title",
"Docker 从入门到实践",
"--site",
str(site),
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
import json
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WORKFLOW_DIR = ROOT / ".github" / "workflows"
FULL_ACTION_SHA = re.compile(r"^[^@\s]+@[0-9a-f]{40}$")
class WorkflowSecurityTests(unittest.TestCase):
@staticmethod
def workflows():
return sorted(WORKFLOW_DIR.glob("*.y*ml"))
def test_all_actions_are_immutable_with_version_comments(self):
failures = []
for workflow in self.workflows():
for number, line in enumerate(workflow.read_text(encoding="utf-8").splitlines(), 1):
match = re.search(r"\buses:\s*([^\s#]+)(?:\s+#\s*(\S+))?", line)
if not match:
continue
action, version = match.groups()
if not FULL_ACTION_SHA.fullmatch(action) or not version or not version.startswith("v"):
failures.append(f"{workflow.name}:{number}: {line.strip()}")
self.assertEqual(failures, [])
def test_checkout_never_persists_credentials(self):
failures = []
for workflow in self.workflows():
lines = workflow.read_text(encoding="utf-8").splitlines()
for index, line in enumerate(lines):
if "uses: actions/checkout@" not in line:
continue
step = "\n".join(lines[index : index + 8])
if "persist-credentials: false" not in step:
failures.append(f"{workflow.name}:{index + 1}")
self.assertEqual(failures, [])
def test_every_workflow_declares_permissions(self):
failures = []
for workflow in self.workflows():
text = workflow.read_text(encoding="utf-8")
before_jobs = text.split("\njobs:", 1)[0]
if not re.search(r"(?m)^permissions:", before_jobs):
failures.append(workflow.name)
self.assertEqual(failures, [])
def test_release_and_preview_separate_read_only_builds_from_writes(self):
expectations = {
"auto-release.yml": ("release",),
"preview-pdf.yml": ("publish",),
}
for name, write_jobs in expectations.items():
text = (WORKFLOW_DIR / name).read_text(encoding="utf-8")
self.assertRegex(text, r"(?ms)^ build:\n permissions:\n contents: read\b", name)
for job in write_jobs:
self.assertRegex(
text,
rf"(?ms)^ {job}:.*?permissions:\n contents: write\b.*?needs: build\b",
name,
)
def test_downloads_dependencies_and_link_checker_are_integrity_pinned(self):
combined = "\n".join(path.read_text(encoding="utf-8") for path in self.workflows())
link_text = (WORKFLOW_DIR / "check-link.yml").read_text(encoding="utf-8")
self.assertNotRegex(combined, r"npm install\s+-g\s+@mermaid-js/mermaid-cli")
self.assertIn("PANDOC_SHA256", combined)
self.assertIn("KUBECONFORM_SHA256", combined)
self.assertIn("ACTIONLINT_SHA256", combined)
self.assertRegex(link_text, r"dkhamsing/awesome_bot@sha256:[0-9a-f]{64}")
def test_mermaid_is_exact_and_lockfile_backed(self):
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
version = package["devDependencies"]["@mermaid-js/mermaid-cli"]
self.assertRegex(version, r"^\d+\.\d+\.\d+$")
self.assertTrue((ROOT / "package-lock.json").is_file())
ignored = {
line.strip()
for line in (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
self.assertNotIn("package-lock.json", ignored)
self.assertTrue(all("npm ci" in path.read_text(encoding="utf-8") for path in self.workflows() if path.name != "check-link.yml" and path.name != "dependabot-automerge.yml"))
def test_artifacts_are_smoke_tested_and_html_failures_are_not_silent(self):
verifier = ROOT / "tools" / "verify_artifacts.py"
self.assertTrue(verifier.is_file())
for name in ("auto-release.yml", "ci.yaml", "preview-pdf.yml"):
text = (WORKFLOW_DIR / name).read_text(encoding="utf-8")
self.assertIn("tools/verify_artifacts.py", text, name)
self.assertIn("SHA256SUMS", text, name)
auto_release = (WORKFLOW_DIR / "auto-release.yml").read_text(encoding="utf-8")
self.assertNotIn("continue-on-error: true", auto_release)
if __name__ == "__main__":
unittest.main()