Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 30 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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:<branch>`. 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).
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Changelog

Release notes for this tutorial, from the
[GitHub releases](https://github.com/libsemigroups/tutorial/releases).

<!-- Release descriptions are added automatically by hooks/changelog.py. -->
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<figure markdown="span" width=50%>
Expand Down
94 changes: 94 additions & 0 deletions hooks/changelog.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ plugins:

hooks:
- hooks/release_version.py
- hooks/changelog.py

markdown_extensions:
- attr_list
Expand Down
116 changes: 116 additions & 0 deletions tests/test_changelog.py
Original file line number Diff line number Diff line change
@@ -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('<h2 id="v100">v1.0.0</h2>', html)
self.assertIn('<h3 id="whats-changed">What\'s Changed</h3>', html)
self.assertIn("<li>Fix <strong>typos</strong>.</li>", 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()
Loading