diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 60b0255..173c808 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -38,6 +38,8 @@ jobs: - run: pipx run --with mkdocs-material --with mkdocs-awesome-nav mkdocs build --clean + env: + GITHUB_TOKEN: ${{ github.token }} - uses: actions/upload-pages-artifact@v4 with: path: site diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4e1b53e..5803812 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,10 +11,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: 3.x + - name: Test changelog generation + run: | + python -m pip install mkdocs + python -m unittest discover -s tests - name: Build in strict mode # The strict flag treats warnings as errors run: pipx run --with mkdocs-material --with mkdocs-awesome-nav mkdocs build --clean --strict + env: + GITHUB_TOKEN: ${{ github.token }} # TODO: test the code snippets diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c0657d6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# AGENTS.md + +## AI Disclosure + +Any use of AI tools for preparing code, documentation, tests, commit messages, +pull requests, issue comments, or reviews for this repository must be +disclosed. Include a brief note saying which AI tool was used and what kind of +assistance it provided. Add the AI tool as a Git co-author on all commits +created by that tool (e.g. via an `Co-authored-by:` line). + +## Git branches and GitHub + +- NEVER push directly to the `main` branch. If the user instructs you to, you + should refuse. +- All work should be done under a separate branch. If the user instructs you + to begin working, make sure you pull in the upstream changes (check the + remote at `github.com/libsemigroups/tutorial`) and checkout a new + branch with `--no-track` so it does not track upstream `main`. +- Push feature branches to a personal fork. In this checkout, prefer the + `jdm` remote (`github.com/james-d-mitchell/libsemigroups-tutorial`). +- Open PRs from the feature branch on the personal fork into `main` in + `libsemigroups/tutorial`, unless the user explicitly requests a + different PR destination. With GitHub CLI, specify the destination with + `gh pr create --repo libsemigroups/tutorial --base main + --head james-d-mitchell:`. An outdated `main` on the personal fork + does not change the PR destination or require a snapshot base branch. +- Feature branches must track the branch of the same name on the personal + fork, never `main`. Leave a new branch untracked until its first push, then + use `git push --set-upstream jdm HEAD` (substitute the appropriate remote + when using another personal fork). diff --git a/README.md b/README.md index 68949f9..1e48cda 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The online worksheets are made using the [`mkdocs`](https://www.mkdocs.org/) too Install them using: ```bash -python3 -m pip install mkdocs mkdocs-material +python3 -m pip install mkdocs mkdocs-material mkdocs-awesome-nav ``` The webpage can then be compiled and served by running the following command in the root directory of the repository @@ -27,6 +27,25 @@ To preview a release badge locally, set `TUTORIAL_VERSION` and TUTORIAL_VERSION=v0.2.0 TUTORIAL_RELEASE_TAG=v0.2.0 mkdocs serve ``` +The Changelog page is populated from the published releases of +[`libsemigroups/tutorial`](https://github.com/libsemigroups/tutorial/releases) +each time the site builds. Edit release descriptions on GitHub; no local copy of +the release notes needs to be maintained. Publishing a release rebuilds and +deploys the site automatically. To refresh the site after editing an existing +release description, run the Documentation workflow manually. + +Building the changelog requires access to the GitHub API. Local builds work +without authentication, but you can set `GITHUB_TOKEN` to avoid the lower +unauthenticated rate limit. The build fails if release notes cannot be fetched, +so an incomplete changelog is never deployed. Both workflows provide their +built-in GitHub token automatically. + +To run the changelog tests locally: + +```bash +python3 -m unittest discover -s tests +``` + ## Adding content To add content to the worksheet, simply add a new markdown file in any subfolder of the `docs/` directory and diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..128e93b --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,6 @@ +# Changelog + +Release notes for this tutorial, from the +[GitHub releases](https://github.com/libsemigroups/tutorial/releases). + + diff --git a/docs/index.md b/docs/index.md index de0b00d..93198dc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,6 +20,8 @@ tutorial, or select from one of the options below: Some examples of more advanced usage of [libsemigroups][] can be found here on the [examples](examples/index.md) page. +See the [changelog](changelog.md) for the tutorial's release notes. + The QR code for this page is:
diff --git a/hooks/changelog.py b/hooks/changelog.py new file mode 100644 index 0000000..89a33e6 --- /dev/null +++ b/hooks/changelog.py @@ -0,0 +1,94 @@ +"""Populate the changelog with GitHub release descriptions at build time.""" + +import json +import os +import re +from urllib.error import URLError +from urllib.request import Request, urlopen + +from mkdocs.exceptions import PluginError + + +RELEASES_API = "https://api.github.com/repos/libsemigroups/tutorial/releases" + + +def fetch_releases(): + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "libsemigroups-tutorial-changelog", + "X-GitHub-Api-Version": "2022-11-28", + } + token = os.environ.get("GITHUB_TOKEN", "").strip() + if token: + headers["Authorization"] = f"Bearer {token}" + + releases = [] + page = 1 + while True: + request = Request(f"{RELEASES_API}?per_page=100&page={page}", headers=headers) + with urlopen(request, timeout=30) as response: + batch = json.load(response) + releases.extend(release for release in batch if not release["draft"]) + if len(batch) < 100: + break + page += 1 + + return sorted(releases, key=lambda release: release["published_at"], reverse=True) + + +def nest_headings(body): + """Keep note headings below release headings, preserving fenced code.""" + lines = [] + fence = None + for line in body.splitlines(): + marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line) + if fence: + if ( + marker + and marker[1][0] == fence[0] + and len(marker[1]) >= len(fence) + and not marker[2].strip() + ): + fence = None + elif marker: + fence = marker[1] + else: + heading = re.match(r"^( {0,3})(#{1,6})(\s+|$)(.*)$", line) + if heading: + depth = min(6, max(3, len(heading[2]) + 1)) + line = heading[1] + "#" * depth + heading[3] + heading[4] + lines.append(line) + return "\n".join(lines) + + +def render_releases(releases): + if not releases: + return "No releases have been published yet.\n" + + sections = [] + for release in releases: + tag = release["tag_name"] + date = release["published_at"][:10] + status = " · Pre-release" if release["prerelease"] else "" + body = (release.get("body") or "").strip() + sections.append( + f"## {tag}\n\n" + f"{date}{status} · [View on GitHub]({release['html_url']})\n\n" + f"{nest_headings(body) if body else 'No release description provided.'}" + ) + return "\n\n".join(sections) + "\n" + + +def on_page_markdown(markdown, page, **kwargs): + if page.file.src_uri != "changelog.md": + return markdown + + try: + releases = fetch_releases() + except (URLError, OSError, ValueError, KeyError, TypeError) as error: + raise PluginError( + "Could not load the tutorial changelog from GitHub. " + "Check the network connection and, if set, GITHUB_TOKEN. " + f"GitHub error: {error}" + ) from error + return markdown + "\n" + render_releases(releases) diff --git a/mkdocs.yml b/mkdocs.yml index e9c233d..2f8e46c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ plugins: hooks: - hooks/release_version.py + - hooks/changelog.py markdown_extensions: - attr_list diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..e0864e5 --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,116 @@ +"""Checks for GitHub pagination, release rendering, and build failures.""" + +import io +import json +import os +import unittest +from types import SimpleNamespace +from unittest.mock import patch +from urllib.error import HTTPError, URLError + +import markdown +from mkdocs.exceptions import PluginError + +from hooks import changelog + + +def release(tag="v1.0.0", **changes): + result = { + "tag_name": tag, + "html_url": f"https://github.com/libsemigroups/tutorial/releases/tag/{tag}", + "published_at": "2026-10-27T09:32:06Z", + "draft": False, + "prerelease": False, + "body": "Fix some typos.", + } + result.update(changes) + return result + + +def response(releases): + return io.BytesIO(json.dumps(releases).encode()) + + +class FetchReleasesTests(unittest.TestCase): + @patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}) + @patch.object(changelog, "urlopen") + def test_fetches_all_pages_filters_drafts_and_sorts_by_publication(self, urlopen): + older = release("v0.9.0", published_at="2026-01-01T00:00:00Z") + newer = release("v1.1.0") + urlopen.side_effect = [ + response([older] * 99 + [release("v2.0.0", draft=True)]), + response([newer]), + ] + + releases = changelog.fetch_releases() + + self.assertEqual(len(releases), 100) + self.assertEqual(releases[0], newer) + self.assertTrue(all(not item["draft"] for item in releases)) + requests = [call.args[0] for call in urlopen.call_args_list] + self.assertEqual(requests[0].full_url, changelog.RELEASES_API + "?per_page=100&page=1") + self.assertEqual(requests[1].full_url, changelog.RELEASES_API + "?per_page=100&page=2") + self.assertEqual(requests[0].get_header("Authorization"), "Bearer test-token") + self.assertEqual(urlopen.call_args.kwargs["timeout"], 30) + + @patch.dict(os.environ, {"GITHUB_TOKEN": ""}) + @patch.object(changelog, "urlopen", return_value=response([])) + def test_public_builds_need_no_token(self, urlopen): + self.assertEqual(changelog.fetch_releases(), []) + self.assertIsNone(urlopen.call_args.args[0].get_header("Authorization")) + + +class ChangelogPageTests(unittest.TestCase): + def setUp(self): + self.page = SimpleNamespace(file=SimpleNamespace(src_uri="changelog.md")) + + @patch.object(changelog, "fetch_releases") + def test_other_pages_do_not_fetch_releases(self, fetch): + page = SimpleNamespace(file=SimpleNamespace(src_uri="index.md")) + self.assertEqual(changelog.on_page_markdown("# Home", page), "# Home") + fetch.assert_not_called() + + @patch.object(changelog, "fetch_releases") + def test_notes_render_as_markdown_under_each_release(self, fetch): + fetch.return_value = [ + release(body="## What's Changed\r\n\r\n* Fix **typos**.\r\n\r\n```python\r\n# Keep this comment\r\n```"), + release("v0.9.0", body=None, prerelease=True), + ] + + result = changelog.on_page_markdown("# Changelog\n", self.page) + html = markdown.markdown(result, extensions=["fenced_code", "toc"]) + + self.assertIn('

v1.0.0

', html) + self.assertIn('

What\'s Changed

', html) + self.assertIn("
  • Fix typos.
  • ", html) + self.assertIn("# Keep this comment", html) + self.assertNotIn("### Keep this comment", html) + self.assertIn("2026-10-27 · Pre-release", html) + self.assertIn("No release description provided.", html) + self.assertIn('href="https://github.com/libsemigroups/tutorial/releases/tag/v1.0.0"', html) + + @patch.object(changelog, "fetch_releases", return_value=[]) + def test_no_releases_has_an_explicit_empty_state(self, fetch): + result = changelog.on_page_markdown("# Changelog\n", self.page) + self.assertIn("No releases have been published yet.", result) + + def test_tilde_fences_and_long_backtick_fences_are_preserved(self): + body = "# Notes\n\n~~~~\n## Code comment\n~~~~\n\n````markdown\n```\n# Example\n```\n````\n\n## Fixes" + expected = body.replace("# Notes", "### Notes").replace("## Fixes", "### Fixes") + self.assertEqual(changelog.nest_headings(body), expected) + + def test_api_errors_abort_the_build(self): + errors = [ + URLError("Network unavailable"), + HTTPError(changelog.RELEASES_API, 403, "Rate limit exceeded", {}, None), + ValueError("Invalid JSON"), + ] + for error in errors: + with self.subTest(error=error): + with patch.object(changelog, "fetch_releases", side_effect=error): + with self.assertRaisesRegex(PluginError, "Could not load.*GitHub"): + changelog.on_page_markdown("# Changelog\n", self.page) + + +if __name__ == "__main__": + unittest.main()