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

- -
- - - - - - - - -
- - - - - - - - - - - - - - - - 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.

+ + + + +""" + + +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": "