ci: schedule weekly README audit

This commit is contained in:
Wilson Freitas
2026-09-02 09:07:53 -03:00
parent ef456f7d77
commit 4dfd553080
5 changed files with 344 additions and 7 deletions
+210 -1
View File
@@ -1,11 +1,16 @@
import io
import os
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from contextlib import redirect_stdout
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import Mock, patch
from github import GithubException
@@ -18,6 +23,8 @@ from scripts.audit_readme import (
TRACKING_TITLE,
audit_readme,
collect_targets,
main,
parse_args,
render_report,
sync_tracking_issue,
)
@@ -131,6 +138,208 @@ class FakeTrackingRepository:
return issue
class AuditReadmeMainTests(unittest.TestCase):
def test_direct_cli_help_works_without_github_environment(self):
environment = os.environ.copy()
environment.pop("GITHUB_TOKEN", None)
environment.pop("GITHUB_REPOSITORY", None)
result = subprocess.run(
[sys.executable, "scripts/audit_readme.py", "--help"],
cwd=Path(__file__).parents[1],
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("--readme", result.stdout)
self.assertEqual(result.stderr, "")
def test_parse_args_defaults_to_readme(self):
with patch("sys.argv", ["audit_readme.py"]):
args = parse_args()
self.assertEqual(args.readme, "README.md")
def test_parse_args_accepts_readme_path(self):
with patch("sys.argv", ["audit_readme.py", "--readme", "docs/list.md"]):
args = parse_args()
self.assertEqual(args.readme, "docs/list.md")
def test_main_rejects_missing_or_blank_environment_variables(self):
cases = (
({"GITHUB_REPOSITORY": "owner/list"}, "GITHUB_TOKEN is required"),
(
{"GITHUB_TOKEN": " \t", "GITHUB_REPOSITORY": "owner/list"},
"GITHUB_TOKEN is required",
),
({"GITHUB_TOKEN": "secret"}, "GITHUB_REPOSITORY is required"),
(
{"GITHUB_TOKEN": "secret", "GITHUB_REPOSITORY": " "},
"GITHUB_REPOSITORY is required",
),
)
for environment, message in cases:
with self.subTest(message=message, environment=environment):
with (
patch.dict("os.environ", environment, clear=True),
patch("scripts.audit_readme.parse_args"),
patch("scripts.audit_readme.Github") as github,
):
with self.assertRaisesRegex(RuntimeError, f"^{message}$"):
main()
github.assert_not_called()
def test_main_publishes_findings_and_returns_zero_with_one_repo_lookup(self):
token = "do-not-print-this-token"
repository = object()
client = SimpleNamespace(get_repo=Mock(return_value=repository))
finding = SimpleNamespace()
checked_at = datetime(2026, 9, 7, 9, 0, tzinfo=timezone.utc)
stdout = io.StringIO()
with (
patch.dict(
"os.environ",
{
"GITHUB_TOKEN": f" {token} ",
"GITHUB_REPOSITORY": " owner/list ",
},
clear=True,
),
patch(
"scripts.audit_readme.parse_args",
return_value=SimpleNamespace(readme="custom.md"),
),
patch("scripts.audit_readme.Auth.Token", return_value="auth") as auth_token,
patch("scripts.audit_readme.Github", return_value=client) as github,
patch(
"scripts.audit_readme.audit_readme",
return_value=[finding],
) as audit,
patch(
"scripts.audit_readme.render_report",
return_value="report body",
) as render,
patch(
"scripts.audit_readme.sync_tracking_issue",
return_value="created",
) as sync,
patch("scripts.audit_readme.datetime") as clock,
redirect_stdout(stdout),
):
clock.now.return_value = checked_at
result = main()
self.assertEqual(result, 0)
auth_token.assert_called_once_with(token)
github.assert_called_once_with(auth="auth")
client.get_repo.assert_called_once_with("owner/list")
audit.assert_called_once_with("custom.md", client)
clock.now.assert_called_once_with(timezone.utc)
render.assert_called_once_with([finding], checked_at=checked_at)
sync.assert_called_once_with(repository, [finding], "report body")
self.assertEqual(stdout.getvalue(), "README audit: 1 finding(s); issue action: created\n")
self.assertNotIn(token, stdout.getvalue())
def test_main_returns_zero_for_multiple_findings_after_publication(self):
findings = [SimpleNamespace(), SimpleNamespace()]
client = SimpleNamespace(get_repo=lambda _name: object())
stdout = io.StringIO()
with (
patch.dict(
"os.environ",
{"GITHUB_TOKEN": "token", "GITHUB_REPOSITORY": "owner/list"},
clear=True,
),
patch(
"scripts.audit_readme.parse_args",
return_value=SimpleNamespace(readme="README.md"),
),
patch("scripts.audit_readme.Github", return_value=client),
patch("scripts.audit_readme.audit_readme", return_value=findings),
patch("scripts.audit_readme.render_report", return_value="body"),
patch("scripts.audit_readme.sync_tracking_issue", return_value="updated"),
redirect_stdout(stdout),
):
result = main()
self.assertEqual(result, 0)
self.assertEqual(
stdout.getvalue(),
"README audit: 2 finding(s); issue action: updated\n",
)
def test_main_propagates_repository_lookup_error(self):
api_error = RuntimeError("API failed")
client = SimpleNamespace(get_repo=Mock(side_effect=api_error))
with (
patch.dict(
"os.environ",
{"GITHUB_TOKEN": "token", "GITHUB_REPOSITORY": "owner/list"},
clear=True,
),
patch(
"scripts.audit_readme.parse_args",
return_value=SimpleNamespace(readme="README.md"),
),
patch("scripts.audit_readme.Github", return_value=client),
patch("scripts.audit_readme.audit_readme") as audit,
):
with self.assertRaises(RuntimeError) as caught:
main()
self.assertIs(caught.exception, api_error)
client.get_repo.assert_called_once_with("owner/list")
audit.assert_not_called()
def test_main_propagates_audit_and_publication_errors(self):
audit_error = RuntimeError("audit failed")
publication_error = RuntimeError("publication failed")
cases = (
("audit", audit_error, None),
("publication", None, publication_error),
)
for name, audit_side_effect, sync_side_effect in cases:
with self.subTest(name=name):
client = SimpleNamespace(get_repo=lambda _name: object())
with (
patch.dict(
"os.environ",
{
"GITHUB_TOKEN": "token",
"GITHUB_REPOSITORY": "owner/list",
},
clear=True,
),
patch(
"scripts.audit_readme.parse_args",
return_value=SimpleNamespace(readme="README.md"),
),
patch("scripts.audit_readme.Github", return_value=client),
patch(
"scripts.audit_readme.audit_readme",
return_value=[] if audit_side_effect is None else None,
side_effect=audit_side_effect,
),
patch("scripts.audit_readme.render_report", return_value="body"),
patch(
"scripts.audit_readme.sync_tracking_issue",
return_value="clean",
side_effect=sync_side_effect,
),
):
expected = audit_side_effect or sync_side_effect
with self.assertRaises(RuntimeError) as caught:
main()
self.assertIs(caught.exception, expected)
class AuditReadmeTests(unittest.TestCase):
def test_render_report_is_a_complete_deterministic_markdown_document(self):
findings = [
+51
View File
@@ -0,0 +1,51 @@
import unittest
from pathlib import Path
WORKFLOW_PATH = Path(__file__).parents[1] / ".github/workflows/readme-audit.yml"
class ReadmeAuditWorkflowTests(unittest.TestCase):
def test_workflow_matches_the_scheduled_audit_contract(self):
expected = """name: README Audit
on:
schedule:
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
contents: read
issues: write
concurrency:
group: readme-audit
cancel-in-progress: false
jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
persist-credentials: false
- uses: astral-sh/setup-uv@v6
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: uv sync --frozen --no-install-project
- name: Audit README links and repositories
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: uv run python scripts/audit_readme.py --readme README.md
"""
self.assertEqual(WORKFLOW_PATH.read_text(encoding="utf-8"), expected)
if __name__ == "__main__":
unittest.main()