From 4c86ba7bffd2f89da0b5898c92c1d149ccdfc6ba Mon Sep 17 00:00:00 2001 From: James Mitchell Date: Tue, 27 Oct 2026 12:29:11 +0000 Subject: [PATCH 1/2] Add a changelog populated from GitHub releases Generate release notes during site builds and link the changelog from Home. Provide GitHub tokens in the build workflows and test pagination, Markdown rendering, empty releases, and API failures. AI assistance: OpenAI Codex implemented the changelog, documentation, tests, and commit message. Co-authored-by: Codex --- .github/workflows/docs.yml | 2 + .github/workflows/tests.yml | 9 +++ README.md | 21 ++++++- docs/changelog.md | 6 ++ docs/index.md | 2 + hooks/changelog.py | 94 +++++++++++++++++++++++++++++ mkdocs.yml | 1 + tests/test_changelog.py | 116 ++++++++++++++++++++++++++++++++++++ 8 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.md create mode 100644 hooks/changelog.py create mode 100644 tests/test_changelog.py 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/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() From 2a5fb362cef7b894fe3d5e42cf6f2e03e36f22de Mon Sep 17 00:00:00 2001 From: James Mitchell Date: Tue, 27 Oct 2026 12:29:11 +0000 Subject: [PATCH 2/2] Document Git branch and GitHub workflow Copy the branch, fork, pull request, and AI disclosure guidance from libsemigroups/AGENTS.md, adapting repository URLs for the tutorial. AI assistance: OpenAI Codex copied and adapted the guidance and prepared this commit message. Co-authored-by: Codex --- AGENTS.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 AGENTS.md 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).