From 867aa69a85e4eb05f73534245aa86024dbd6eff9 Mon Sep 17 00:00:00 2001 From: MobyNL Date: Tue, 18 Aug 2026 21:50:28 +0200 Subject: [PATCH] docs: publish the keyword documentation per version The rendered libdoc page was committed to main and served from the repository root, so there was one page: whatever was newest. Someone on 0.1.0 read 0.2.0's keywords. A workflow now generates it and publishes to gh-pages, one directory per release plus /dev for main, with /latest and the bare path following the newest release - the bare path is what the metadata of the already published releases points at, so it stays served. tools/build_docs_index.py renders the landing page from a versions.json the workflow maintains, and is tested rather than verified by publishing and looking. The page is no longer committed. It records its generation time, the absolute path of the machine that produced it and the Robot Framework and Python versions used, so a committed copy cannot be compared against a fresh one. Generating on publish leaves nothing that can drift. workflow_dispatch takes a ref, because a dispatch can only run a workflow that exists on the chosen ref and v0.1.0 and v0.2.0 predate this one. Naming the tag publishes it. /latest moves only when the version published is really the newest release, so backfilling an old tag cannot point it at older documentation. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/docs.yml | 142 +++++++++++++ .gitignore | 4 + .nojekyll | 0 CHANGELOG.md | 12 ++ GraphQLLibraryKeywords.html | 387 ------------------------------------ HANDOFF.md | 9 +- README.md | 15 +- index.html | 5 - tools/build_docs_index.py | 189 ++++++++++++++++++ utest/test_docs_index.py | 149 ++++++++++++++ 10 files changed, 516 insertions(+), 396 deletions(-) create mode 100644 .github/workflows/docs.yml delete mode 100644 .nojekyll delete mode 100644 GraphQLLibraryKeywords.html delete mode 100644 index.html create mode 100644 tools/build_docs_index.py create mode 100644 utest/test_docs_index.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..d2b7fd7 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,142 @@ +name: Documentation + +# The keyword documentation is generated from the library itself and published to the +# gh-pages branch, one directory per released version plus /dev for the current main. +# +# It is not committed to the repository. A rendered libdoc page carries the generation +# time, the absolute path of the machine that produced it and the Robot Framework and +# Python versions used, so a committed copy cannot be compared against a fresh one +# without normalising all of that away. Generating on publish means there is nothing +# that can drift. + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + inputs: + ref: + # workflow_dispatch can only run a workflow that exists on the chosen ref, so the + # tags released before this workflow did cannot be dispatched directly. Naming one + # here publishes it: run the workflow from main with ref set to v0.1.0. + description: "Tag to publish instead of the current main, for example v0.1.0" + required: false + default: "" + +permissions: + contents: read + +concurrency: + # Publishing rewrites a shared branch, so two runs must not do it at once. + group: docs + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + # Only this job writes, and only to the gh-pages branch. + contents: write + steps: + - name: Work out what is being published + id: target + run: | + requested="${{ inputs.ref }}" + if [ -n "$requested" ]; then + # A backfill of an older tag. Its version is the tag without the v. + case "$requested" in + v*) ;; + *) echo "The ref input has to be a version tag, for example v0.1.0." >&2; exit 1 ;; + esac + echo "ref=$requested" >> "$GITHUB_OUTPUT" + echo "path=${requested#v}" >> "$GITHUB_OUTPUT" + echo "release=true" >> "$GITHUB_OUTPUT" + elif [ "${GITHUB_REF_TYPE}" = "tag" ]; then + echo "ref=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + echo "path=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + echo "release=true" >> "$GITHUB_OUTPUT" + else + echo "ref=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + echo "path=dev" >> "$GITHUB_OUTPUT" + echo "release=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v7 + with: + # Tags are needed so a backfilled ref can be checked out at all. + fetch-depth: 0 + ref: ${{ steps.target.outputs.ref }} + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Install Poetry + run: pipx install poetry + + - name: Install + run: poetry install + + - name: Generate the keyword documentation + run: poetry run python -m robot.libdoc GraphQLLibrary GraphQLLibraryKeywords.html + + - name: Take the index builder from main + # An older tag has no tools/ of its own, and the page it renders is the same page + # regardless of which version's keywords are being documented. + if: ${{ inputs.ref != '' }} + run: | + git fetch origin main --depth 1 + git checkout FETCH_HEAD -- tools/build_docs_index.py + + - name: Check out the published site + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git ls-remote --exit-code --heads origin gh-pages; then + git fetch origin gh-pages --depth 1 + git worktree add site FETCH_HEAD + else + # First publish: start the branch with no history of its own. + git worktree add --detach site + git -C site checkout --orphan gh-pages + git -C site rm -rf . --quiet || true + fi + + - name: Add this version to the site + run: | + set -eu + path="${{ steps.target.outputs.path }}" + mkdir -p "site/${path}" + cp GraphQLLibraryKeywords.html "site/${path}/GraphQLLibraryKeywords.html" + + # GitHub Pages otherwise runs the site through Jekyll, which drops directories + # whose names begin with an underscore and needs no help here regardless. + touch site/.nojekyll + + if [ "${{ steps.target.outputs.release }}" = "true" ]; then + poetry run python tools/build_docs_index.py \ + site/versions.json site/index.html --add "$path" --release + else + poetry run python tools/build_docs_index.py \ + site/versions.json site/index.html --add "$path" + fi + + # /latest and the bare path are only moved when the version just published is + # actually the newest release, so backfilling an old tag cannot demote them. The + # bare path is what the released package metadata points at, so it stays served. + if poetry run python tools/build_docs_index.py --is-latest "$path" site/versions.json; then + mkdir -p site/latest + cp GraphQLLibraryKeywords.html site/latest/GraphQLLibraryKeywords.html + cp GraphQLLibraryKeywords.html site/GraphQLLibraryKeywords.html + fi + + - name: Publish + run: | + cd site + git add -A + if git diff --cached --quiet; then + echo "The published documentation is already up to date." + exit 0 + fi + git commit -m "docs: publish ${{ steps.target.outputs.path }} from ${GITHUB_SHA}" + git push origin HEAD:gh-pages diff --git a/.gitignore b/.gitignore index f81ec37..ae6b871 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ build/ atest-results/ libdoc-check.html +# The keyword documentation is generated and published by the docs workflow, per version, +# and never committed: a rendered libdoc page carries the generation time, the path of the +# machine that made it and the Robot Framework and Python versions used. +GraphQLLibraryKeywords.html log.html report.html output.xml diff --git a/.nojekyll b/.nojekyll deleted file mode 100644 index e69de29..0000000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbc9f3..b14c24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- The keyword documentation is now generated on publish and served per version, at + `//GraphQLLibraryKeywords.html`, with `/latest` and `/dev` alongside them and a + landing page listing everything. Reading the documentation for the version you have installed + no longer means reading the documentation for whatever is newest. The rendered page is no + longer committed to the repository: it records its own generation time, the path of the + machine that produced it and the Robot Framework and Python versions used, none of which + belongs in version control and all of which made a committed copy impossible to verify. + ## [0.2.0] - 2026-08-17 ### Added diff --git a/GraphQLLibraryKeywords.html b/GraphQLLibraryKeywords.html deleted file mode 100644 index 03619d1..0000000 --- a/GraphQLLibraryKeywords.html +++ /dev/null @@ -1,387 +0,0 @@ - - - - - - - - - - - - - -
-

Opening library documentation failed

-
    -
  • Verify that you have JavaScript enabled in your browser.
  • -
  • -Make sure you are using a modern enough browser. If using -Internet Explorer, version 11 is required. -
  • -
  • -Check are there messages in your browser's -JavaScript error log. Please report the problem if you suspect -you have encountered a bug. -
  • -
-
- - - - - - - - -
- - - - - - - - - - - - - - - - diff --git a/HANDOFF.md b/HANDOFF.md index 2f99d9a..fbe00b8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -148,9 +148,12 @@ surface rather than protocol work. asked for it. That is the honest state. 2. First commit and a GitHub repository under `MobyNl`. Conventional commits, matching the MongoDB library. -3. Publish keyword documentation via GitHub Pages: `GraphQLLibraryKeywords.html` and - `index.html` are committed, and `.nojekyll` is present because Jekyll would otherwise eat - libdoc's `{{ }}` sequences. +3. Keyword documentation is published by `.github/workflows/docs.yml` to the `gh-pages` + branch, one directory per release plus `/dev` for main, with `tools/build_docs_index.py` + rendering the landing page. Nothing is committed to `main`, so the pages cannot drift from + the code. GitHub Pages has to be set to serve from `gh-pages`, and the two tags released + before the workflow existed are published by dispatching it with `ref` set to `v0.1.0` and + `v0.2.0`. 4. Set up PyPI trusted publishing for the `pypi` environment, then tag `v0.1.0`. `release.yml` checks the tag against `poetry version --short` before building. 5. Try it against a real API before 1.0 — a public one such as countries.trevorblades.com, diff --git a/README.md b/README.md index 4f9fcf3..96f04c3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ GraphQL test library for Robot Framework. -[Keyword documentation](https://mobynl.github.io/robotframework-graphqllibrary/) +[Keyword documentation](https://mobynl.github.io/robotframework-graphqllibrary/), published per version. ## Why not just RequestsLibrary @@ -143,6 +143,19 @@ reported. Drift detection compares types and fields, not the built-in directives `Execute Raw Request` sends an operation with no checking at all, for cases this library does not model. +## Documentation + +The [keyword documentation](https://mobynl.github.io/robotframework-graphqllibrary/) describes +every keyword, its arguments and examples. It is published per version, so you can read the +documentation for the version you actually have installed rather than for whatever is newest: + +- [all versions](https://mobynl.github.io/robotframework-graphqllibrary/) — start here +- [latest release](https://mobynl.github.io/robotframework-graphqllibrary/latest/GraphQLLibraryKeywords.html) +- [current main, unreleased](https://mobynl.github.io/robotframework-graphqllibrary/dev/GraphQLLibraryKeywords.html) + +The pages are generated from the library itself when a tag or a push to main is published, so +they cannot drift from the code they document. + ## Development ``` diff --git a/index.html b/index.html deleted file mode 100644 index d6e46fa..0000000 --- a/index.html +++ /dev/null @@ -1,5 +0,0 @@ - - -robotframework-graphql keyword documentation - -Keyword documentation diff --git a/tools/build_docs_index.py b/tools/build_docs_index.py new file mode 100644 index 0000000..8d92494 --- /dev/null +++ b/tools/build_docs_index.py @@ -0,0 +1,189 @@ +"""Builds the landing page for the published keyword documentation. + +The documentation is published per version, so a reader can look up the version they +actually have installed rather than whatever happens to be newest. This renders the page +that lists them, from the `versions.json` the publishing workflow maintains. + +It lives here rather than inside the workflow so that the page can be tested, and so that +the workflow stays readable. +""" + +import argparse +import html +import json +import sys +from pathlib import Path +from typing import Any, Dict, List + +DOCUMENT = "GraphQLLibraryKeywords.html" + +TEMPLATE = """ + + + + +GraphQLLibrary keyword documentation + + + +

GraphQLLibrary

+

Keyword documentation, per released version.

+
    +{entries} +
+
+

The library is below 1.0.0, so the keyword surface can still change between releases. +Read the page for the version you have installed; the +changelog +records what changed.

+

Source on GitHub

+
+ + +""" + + +def _entry(version: Dict[str, Any]) -> str: + """Renders one line of the list.""" + name = html.escape(str(version["version"])) + path = html.escape(str(version["path"])) + tags = "".join(f'{html.escape(tag)}' for tag in version.get("tags", [])) + return f'
  • {name}{tags}
  • ' + + +def render(versions: List[Dict[str, Any]]) -> str: + """Renders the landing page for the given versions, newest first.""" + if not versions: + entries = "
  • No documentation has been published yet.
  • " + else: + entries = "\n".join(_entry(version) for version in versions) + return TEMPLATE.format(entries=entries) + + +UNRELEASED = "unreleased" + + +def update_versions(versions: List[Dict[str, Any]], path: str, is_release: bool) -> List[Dict[str, Any]]: + """Records a published version, and works out which release is the newest. + + `path` is the directory it was published under: a version number for a release, or + `dev` for the current main. Publishing the same one twice replaces its entry rather + than adding a second. + """ + versions = [version for version in versions if version["path"] != path] + versions.append( + { + "version": path if is_release else "main (unreleased)", + "path": path, + "tags": [] if is_release else [UNRELEASED], + } + ) + releases = [version for version in versions if UNRELEASED not in version.get("tags", [])] + if releases: + newest = max(releases, key=lambda version: _release_order(version["path"])) + for version in releases: + version["tags"] = ["latest"] if version is newest else [] + return versions + + +def is_latest(versions: List[Dict[str, Any]], path: str) -> bool: + """Whether `path` is the release currently tagged as the newest one.""" + for version in versions: + if version["path"] == path: + return "latest" in version.get("tags", []) + return False + + +def _release_order(path: str) -> Any: + """Orders release directories by version number, treating odd ones as oldest.""" + try: + return tuple(int(part) for part in path.split(".")) + except ValueError: + return () + + +def _sort_key(version: Dict[str, Any]) -> Any: + """Orders releases newest first, with anything unreleased above them.""" + raw = str(version["version"]) + parts = raw.split(".") + try: + return (0, tuple(-int(part) for part in parts)) + except ValueError: + # 'dev' and anything else that is not a release number. + return (-1, ()) + + +def main(argv: List[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("versions", type=Path, help="the versions.json to read and write") + parser.add_argument( + "output", + type=Path, + nargs="?", + help="where to write index.html; left out when only asking --is-latest", + ) + parser.add_argument("--add", help="the directory a version was just published under", default=None) + parser.add_argument( + "--release", + action="store_true", + help="the published version is a release rather than the current main", + ) + parser.add_argument( + "--is-latest", + metavar="PATH", + default=None, + help="ask whether this directory holds the newest release; exits 0 if it does, 1 if it does not," + " and writes nothing", + ) + arguments = parser.parse_args(argv) + + versions: List[Dict[str, Any]] = [] + if arguments.versions.exists(): + versions = json.loads(arguments.versions.read_text(encoding="utf-8")) + + if arguments.is_latest is not None: + # A question, not a publish: the workflow asks before it moves /latest, so that + # publishing an older tag late cannot point it at the wrong version. + return 0 if is_latest(versions, arguments.is_latest) else 1 + + if arguments.output is None: + parser.error("an output path is required unless --is-latest is given") + if arguments.add is not None: + versions = update_versions(versions, arguments.add, arguments.release) + arguments.versions.write_text(json.dumps(versions, indent=2) + "\n", encoding="utf-8") + + versions.sort(key=_sort_key) + arguments.output.write_text(render(versions), encoding="utf-8") + return 0 + + +if __name__ == "__main__": # pragma: no cover - entry point + raise SystemExit(main(sys.argv[1:])) diff --git a/utest/test_docs_index.py b/utest/test_docs_index.py new file mode 100644 index 0000000..4e740e9 --- /dev/null +++ b/utest/test_docs_index.py @@ -0,0 +1,149 @@ +"""Tests for the tool that builds the published documentation index. + +This code runs once per release, in a workflow, where a mistake is only noticed after the +fact and shows up as a broken or misleading documentation site. That is a good reason to +test it here rather than by publishing and looking. +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools")) + +import build_docs_index # noqa: E402 - needs the path above + + +class TestUpdateVersions: + def test_the_first_release_becomes_the_latest(self): + versions = build_docs_index.update_versions([], "0.1.0", is_release=True) + assert versions[0]["version"] == "0.1.0" + assert versions[0]["tags"] == ["latest"] + + def test_a_newer_release_takes_the_latest_tag_over(self): + versions = build_docs_index.update_versions([], "0.1.0", is_release=True) + versions = build_docs_index.update_versions(versions, "0.2.0", is_release=True) + tags = {version["path"]: version["tags"] for version in versions} + assert tags["0.2.0"] == ["latest"] + assert tags["0.1.0"] == [] + + def test_an_older_release_published_late_does_not_steal_latest(self): + """Backfilling 0.1.0 after 0.2.0 is out must not move the latest tag back.""" + versions = build_docs_index.update_versions([], "0.2.0", is_release=True) + versions = build_docs_index.update_versions(versions, "0.1.0", is_release=True) + tags = {version["path"]: version["tags"] for version in versions} + assert tags["0.2.0"] == ["latest"] + assert tags["0.1.0"] == [] + + def test_versions_are_compared_as_numbers_not_as_text(self): + """As text, '0.10.0' sorts before '0.9.0', which would be wrong.""" + versions = build_docs_index.update_versions([], "0.9.0", is_release=True) + versions = build_docs_index.update_versions(versions, "0.10.0", is_release=True) + tags = {version["path"]: version["tags"] for version in versions} + assert tags["0.10.0"] == ["latest"] + assert tags["0.9.0"] == [] + + def test_the_development_build_is_never_the_latest(self): + versions = build_docs_index.update_versions([], "0.2.0", is_release=True) + versions = build_docs_index.update_versions(versions, "dev", is_release=False) + tags = {version["path"]: version["tags"] for version in versions} + assert tags["dev"] == ["unreleased"] + assert tags["0.2.0"] == ["latest"] + + def test_publishing_the_same_path_twice_replaces_its_entry(self): + """main is published on every push, and must not accumulate entries.""" + versions = build_docs_index.update_versions([], "dev", is_release=False) + versions = build_docs_index.update_versions(versions, "dev", is_release=False) + assert len(versions) == 1 + + def test_a_release_published_twice_replaces_its_entry(self): + versions = build_docs_index.update_versions([], "0.2.0", is_release=True) + versions = build_docs_index.update_versions(versions, "0.2.0", is_release=True) + assert len(versions) == 1 + assert versions[0]["tags"] == ["latest"] + + +class TestIsLatest: + def test_the_newest_release_is_the_latest(self): + versions = build_docs_index.update_versions([], "0.2.0", is_release=True) + assert build_docs_index.is_latest(versions, "0.2.0") + + def test_an_older_release_is_not(self): + versions = build_docs_index.update_versions([], "0.2.0", is_release=True) + versions = build_docs_index.update_versions(versions, "0.1.0", is_release=True) + assert not build_docs_index.is_latest(versions, "0.1.0") + + def test_the_development_build_is_not(self): + versions = build_docs_index.update_versions([], "dev", is_release=False) + assert not build_docs_index.is_latest(versions, "dev") + + def test_a_path_that_was_never_published_is_not(self): + assert not build_docs_index.is_latest([], "0.2.0") + + +class TestRendering: + def test_versions_are_listed_newest_first_with_development_above_them(self): + versions = [ + {"version": "0.1.0", "path": "0.1.0", "tags": []}, + {"version": "main (unreleased)", "path": "dev", "tags": ["unreleased"]}, + {"version": "0.2.0", "path": "0.2.0", "tags": ["latest"]}, + ] + versions.sort(key=build_docs_index._sort_key) + assert [version["path"] for version in versions] == ["dev", "0.2.0", "0.1.0"] + + def test_each_version_links_to_its_own_page(self): + page = build_docs_index.render([{"version": "0.2.0", "path": "0.2.0", "tags": ["latest"]}]) + assert 'href="0.2.0/GraphQLLibraryKeywords.html"' in page + assert "latest" in page + + def test_an_empty_site_says_so_rather_than_rendering_nothing(self): + assert "No documentation has been published" in build_docs_index.render([]) + + def test_the_page_says_the_keyword_surface_can_still_change(self): + """The library is below 1.0.0, and a reader on an older page should know that.""" + assert "below 1.0.0" in build_docs_index.render([]) + + def test_version_names_are_escaped(self): + """The name comes from a tag, and a tag can contain anything.""" + page = build_docs_index.render([{"version": "