From 47c342c64fae0b88f9ff020aef7adf5f41c78ad0 Mon Sep 17 00:00:00 2001 From: Fengyun Liu Date: Sun, 18 Oct 2026 21:12:29 +0200 Subject: [PATCH 1/5] Add GitHub Pages site and versioned release API docs --- .github/workflows/ci.yml | 7 +++ .github/workflows/docs.yml | 71 ++++++++++++++++++++++++ .gitignore | 4 ++ CHANGELOG.md | 6 ++ README.md | 2 + RELEASE.md | 82 +++++++++++++++++++++++++++- docs/api-versions.jsonl | 2 + docs/config.toml | 9 +++ docs/content/_index.md | 3 + docs/static/style.css | 69 +++++++++++++++++++++++ docs/templates/index.html | 58 ++++++++++++++++++++ jo.toml | 4 +- scripts/package-api-docs.py | 52 ++++++++++++++++++ scripts/prepare-site.py | 96 ++++++++++++++++++++++++++++++++ scripts/test-docs.py | 106 ++++++++++++++++++++++++++++++++++++ 15 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/api-versions.jsonl create mode 100644 docs/config.toml create mode 100644 docs/content/_index.md create mode 100644 docs/static/style.css create mode 100644 docs/templates/index.html create mode 100644 scripts/package-api-docs.py create mode 100644 scripts/prepare-site.py create mode 100644 scripts/test-docs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 910e89b..60a2626 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,10 @@ jobs: jo package core jo package python jo package ruby + - name: Build API documentation archives + run: | + jo doc core + jo doc python + jo doc ruby + version=$(python3 -c 'import tomllib; print(tomllib.load(open("jo.toml", "rb"))["module"]["core"]["package"]["version"])') + python3 scripts/package-api-docs.py "$version" --output "$RUNNER_TEMP/jtest-api-docs" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..cd1844b --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,71 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'scripts/*docs.py' + - 'scripts/prepare-site.py' + - 'README.md' + - 'CHANGELOG.md' + - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - 'docs/**' + - 'scripts/*docs.py' + - 'scripts/prepare-site.py' + - 'README.md' + - 'CHANGELOG.md' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: true + +env: + ZOLA_VERSION: '0.22.1' + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python3 scripts/test-docs.py + - name: Download released API documentation and prepare changelog + run: python3 scripts/prepare-site.py + - name: Install Zola + run: | + curl -fsSL "https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \ + | tar xz -C /usr/local/bin zola + - name: Build site + working-directory: docs + run: zola build + - uses: actions/upload-pages-artifact@v4 + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + with: + path: docs/public + + deploy: + needs: build + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/configure-pages@v5 + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 1c7118e..31c38f9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ jo.lock __pycache__/ *.jo-before.txt *.jo-after.txt +docs/public/ +docs/static/api/ +docs/api-docs.json +docs/changelog.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 03df7c0..9458516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added + +- A GitHub Pages site with the changelog and latest API documentation for all + three packages. Earlier API versions remain available at versioned URLs. +- Release API documentation archives with the README as their introduction. + ### Fixed - `Runner.runWithArgs` uses normal process exit with status 1 after reporting diff --git a/README.md b/README.md index 4947f0b..2b10ede 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A testing framework for Jo, with a shared API and Python and Ruby runners. +[API documentation and changelog](https://typescope.github.io/jtest/). + | Package | Platform | Provides | | --- | --- | --- | | `jtest` | pure | Suite declarations, assertions, and assertion results | diff --git a/RELEASE.md b/RELEASE.md index 9902212..82cb515 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,15 +7,93 @@ in `typescope/jtest`. Their registrations in `typescope/packages` use namespace 1. Set all three package versions in `jo.toml`. 2. Use the CI compiler version and run both conformance apps and the report parity check from README.md. -3. Run `jo package core`, `jo package python`, and `jo package ruby`. +3. Run `jo package core`, `jo package python`, and `jo package ruby`. Generate + and package the API documentation as described below. 4. Inspect each artifact's `meta.toml`. The core must be pure with no package dependencies; each runner must depend only on the matching core version. 5. Verify every `.sha512` file from its `.build//release/` directory. 6. Commit the reviewed sources, wait for green CI, tag that commit `vVERSION`, - and publish the `.joy`, source ZIP, and both checksums for every package. + and publish the `.joy`, source ZIP, API documentation ZIP, and their checksums + for every package. 7. Once registrations are merged, trigger `sync-releases.yml` in `typescope/packages` and verify all three versions at `pkg.jo-lang.org`. Never replace published artifacts or move a published tag. Package registration is a reviewed PR; leave merges to a maintainer. Do not switch downstream users until all three versions are available from the public registry. + +## API documentation assets + +Generate documentation from the same clean, merged release commit as the +packages, using the CI compiler. All three modules use `README.md` as the API +documentation home page. Set `VERSION` to the package version in `jo.toml`: + +```sh +VERSION=0.2.0 +jo doc core +jo doc python +jo doc ruby +python3 scripts/package-api-docs.py "$VERSION" --output "/tmp/jtest-api-release/v$VERSION" +``` + +The script checks that the generated version and README match the checkout. +It produces these archives and a `.sha512` file for each: + +- `jtest-api-docs-vVERSION.zip` +- `jtest-python-api-docs-vVERSION.zip` +- `jtest-ruby-api-docs-vVERSION.zip` + +Each archive contains `//index.html`, `data.js`, and assets. +Verify the checksums from the output directory: + +```sh +(cd "/tmp/jtest-api-release/v$VERSION" && sha512sum --check *.sha512) +``` + +Include all six files when creating the GitHub release alongside the package +assets. If the release already exists, add the new documentation assets with: + +```sh +gh release upload "v$VERSION" "/tmp/jtest-api-release/v$VERSION/"* \ + --repo typescope/jtest +``` + +Do not use `--clobber`. To add docs for an older release, check out its published +tag in a separate directory, use that release's compiler, and add the same +`--readme README.md` and package `--title` doc options if they are missing. +Generate and package from those released sources, including their original +README. Do not generate an older version's API docs from current `main`. + +## GitHub Pages + +After uploading the API assets, append `{"version": "VERSION"}` to +`docs/api-versions.jsonl` in a PR. Keep all existing entries: the site downloads +and verifies the archives for every listed version on every build. Only append +a version once its three API archives and checksums are available in the release. + +The site shows only the latest version per package. Documentation URLs are: + +- `https://typescope.github.io/jtest/api//latest/` +- `https://typescope.github.io/jtest/api///` + +The latest aliases preserve API symbol links, and previous version paths remain +accessible when a new release is added. The changelog is rendered directly from +the repository's `CHANGELOG.md`. + +Set the repository's Pages source to **GitHub Actions** once. The `Docs` workflow +checks PRs and deploys site changes merged to `main`. To republish manually: + +```sh +gh workflow run docs.yml --repo typescope/jtest --ref main +``` + +For a local preview, install Zola 0.22.1, then run: + +```sh +python3 scripts/test-docs.py +python3 scripts/prepare-site.py +zola --root docs serve +``` + +`prepare-site.py` regenerates ignored site data and the downloaded API tree; +generated `.build/` documentation is only read when packaging release assets. diff --git a/docs/api-versions.jsonl b/docs/api-versions.jsonl new file mode 100644 index 0000000..b5234ed --- /dev/null +++ b/docs/api-versions.jsonl @@ -0,0 +1,2 @@ +{"version": "0.1.0"} +{"version": "0.2.0"} diff --git a/docs/config.toml b/docs/config.toml new file mode 100644 index 0000000..f943605 --- /dev/null +++ b/docs/config.toml @@ -0,0 +1,9 @@ +base_url = "https://typescope.github.io/jtest" +title = "jtest" +description = "A testing framework for Jo, with a shared API and Python and Ruby runners." +compile_sass = false +build_search_index = false +generate_feeds = false + +[extra] +github = "https://github.com/typescope/jtest" diff --git a/docs/content/_index.md b/docs/content/_index.md new file mode 100644 index 0000000..ef6083b --- /dev/null +++ b/docs/content/_index.md @@ -0,0 +1,3 @@ ++++ +template = "index.html" ++++ diff --git a/docs/static/style.css b/docs/static/style.css new file mode 100644 index 0000000..081a003 --- /dev/null +++ b/docs/static/style.css @@ -0,0 +1,69 @@ +:root { + color-scheme: light dark; + --paper: #fbfcfa; + --ink: #1c3028; + --muted: #65736b; + --line: #dce4dc; + --accent: #19664d; + --wash: #edf3ed; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--ink); + background: var(--paper); + line-height: 1.6; +} +* { box-sizing: border-box; } +html { scroll-padding-top: 2rem; } +body { max-width: 880px; padding: 0 32px; margin: auto; } +a { color: var(--accent); text-underline-offset: 4px; } +a:hover { text-decoration-thickness: 2px; } +a:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; } +code { font-family: "SFMono-Regular", Consolas, monospace; font-size: .91em; } +.site-header { display: flex; align-items: center; justify-content: space-between; padding: 24px 0; border-bottom: 1px solid var(--line); } +.wordmark { font-size: 24px; font-weight: 750; letter-spacing: -1px; text-decoration: none; color: var(--ink); } +.wordmark span { color: var(--accent); } +nav, footer div { display: flex; gap: 24px; } +nav a, footer a { font-size: 14px; text-decoration: none; } +.skip-link { position: absolute; left: 16px; top: -80px; padding: 10px; background: var(--paper); } +.skip-link:focus { top: 12px; } +.intro { padding: 68px 0 48px; } +.eyebrow { margin: 0 0 12px; text-transform: uppercase; font-size: 12px; letter-spacing: .16em; font-weight: 700; color: var(--accent); } +h1 { margin: 0 0 16px; font-size: clamp(48px, 8vw, 72px); line-height: 1; letter-spacing: -3px; font-weight: 750; } +.description { font-size: 22px; max-width: 520px; line-height: 1.5; margin: 0 0 12px; } +.intro-detail { max-width: 540px; color: var(--muted); margin: 0; } +section + section { border-top: 1px solid var(--line); padding: 34px 0 40px; } +h2 { margin: 0 0 12px; font-size: 24px; letter-spacing: -.5px; } +.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } +.section-note { white-space: nowrap; font-size: 12px; font-weight: 600; color: var(--accent); background: var(--wash); padding: 4px 10px; border-radius: 4px; } +#api > p { color: var(--muted); margin: 0 0 22px; } +table { border-collapse: collapse; width: 100%; margin: 24px 0 18px; text-align: left; } +th, td { padding: 16px 12px; border-bottom: 1px solid var(--line); } +th:first-child { padding-left: 0; } +thead th { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } +tbody th { font-weight: 600; } +td:last-child { text-align: right; padding-right: 0; } +thead th:last-child { text-align: right; padding-right: 0; } +td a { font-size: 14px; font-weight: 600; text-decoration: none; } +#api .version-note { font-size: 13px; margin-bottom: 0; } +.changelog h3 { font-size: 19px; font-weight: 650; margin: 30px 0 12px; } +.changelog h4 { font-size: 14px; color: var(--muted); margin: 20px 0 8px; } +.changelog ul { padding-left: 22px; margin: 0 0 20px; } +.changelog li { padding-left: 4px; margin: 8px 0; } +.changelog code { overflow-wrap: anywhere; background: var(--wash); padding: 2px 4px; border-radius: 3px; } +footer { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; border-top: 1px solid var(--line); padding: 22px 0 32px; font-size: 13px; color: var(--muted); } +footer p { margin: 0; } +@media (max-width: 540px) { + body { padding: 0 20px; } + nav { gap: 16px; } + nav a { font-size: 13px; } + .intro { padding: 48px 0 36px; } + .description { font-size: 20px; } + .section-heading { align-items: flex-start; } + .section-heading h2 { font-size: 22px; } + th, td { padding: 14px 6px; font-size: 13px; } + thead th { font-size: 10px; } + .version-note code { overflow-wrap: anywhere; } + footer div { gap: 16px; } +} +@media (prefers-color-scheme: dark) { + :root { --paper: #17231e; --ink: #e4ebe5; --muted: #a6b5aa; --line: #34473b; --accent: #91cfad; --wash: #24392d; } +} diff --git a/docs/templates/index.html b/docs/templates/index.html new file mode 100644 index 0000000..b77849f --- /dev/null +++ b/docs/templates/index.html @@ -0,0 +1,58 @@ + + + + + + + {{ config.title }} — Testing for Jo + + + + + +
+
+

Testing for Jo

+

jtest

+

A shared testing API, with Python and Ruby runners.

+

Declare suites, collect assertion failures, and run independent tests in parallel.

+
+
+
+

API documentation

+ Latest release +
+

Getting started, installation, and API reference for each package.

+ {% set api = load_data(path="api-docs.json") %} + + + + {% for library in api.libraries %} + + + + + + {% endfor %} + +
PackagePlatformDocumentation
{{ library.name }}{{ library.platform }}v{{ library.version }}
+

Earlier docs remain at api/<package>/<version>/.

+
+
+ {% set changelog = load_data(path="changelog.md", format="plain") %} + {{ changelog | markdown | safe }} +
+
+ + + diff --git a/jo.toml b/jo.toml index 3a5c84f..d3c9fdb 100644 --- a/jo.toml +++ b/jo.toml @@ -5,7 +5,7 @@ kind = "lib" platform = "pure" src = ["src/core/"] compile-options = ["--test-pickling", "--check-tree"] -doc-options = ["--title", "jtest"] +doc-options = ["--title", "jtest", "--readme", "README.md"] [module.core.package] name = "jtest" @@ -18,6 +18,7 @@ enable-ffi = true src = ["src/runner/", "src/python/"] modules = ["core"] compile-options = ["--test-pickling", "--check-tree"] +doc-options = ["--title", "jtest-python", "--readme", "README.md"] [module.python.package] name = "jtest-python" @@ -30,6 +31,7 @@ enable-ffi = true src = ["src/runner/", "src/ruby/"] modules = ["core"] compile-options = ["--test-pickling", "--check-tree"] +doc-options = ["--title", "jtest-ruby", "--readme", "README.md"] [module.ruby.package] name = "jtest-ruby" diff --git a/scripts/package-api-docs.py b/scripts/package-api-docs.py new file mode 100644 index 0000000..454b23a --- /dev/null +++ b/scripts/package-api-docs.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Package generated Jo API docs and SHA-512 checksums for a GitHub release.""" + +import argparse +import hashlib +import json +import re +import zipfile +from pathlib import Path + + +PACKAGES = {"core": "jtest", "python": "jtest-python", "ruby": "jtest-ruby"} + + +def package_docs(version, output, root=Path(".")): + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + raise ValueError(f"expected a release version such as 0.2.0, got {version!r}") + sources = [] + for module, package in PACKAGES.items(): + source = root / ".build" / module / "doc" + for name in ("index.html", "data.js"): + if not (source / name).is_file(): + raise ValueError(f"missing {source / name}; run jo doc {module}") + data = json.loads((source / "data.js").read_text().split("=", 1)[1].rstrip(";\n")) + if data["meta"].get("version") != version: + raise ValueError(f"{source}: generated docs do not match version {version}") + if data.get("home") != (root / "README.md").read_text(): + raise ValueError(f"{source}: regenerate docs with --readme README.md") + sources.append((package, source)) + + output.mkdir(parents=True, exist_ok=True) + for package, source in sources: + archive = output / f"{package}-api-docs-v{version}.zip" + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zipped: + for path in sorted(source.rglob("*")): + if path.is_file(): + target = Path(package) / version / path.relative_to(source) + zipped.write(path, target.as_posix()) + digest = hashlib.sha512(archive.read_bytes()).hexdigest() + archive.with_name(archive.name + ".sha512").write_text(f"{digest} {archive.name}\n") + print(archive) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + try: + package_docs(args.version, args.output) + except ValueError as error: + parser.exit(1, f"{error}\n") diff --git a/scripts/prepare-site.py b/scripts/prepare-site.py new file mode 100644 index 0000000..b700b96 --- /dev/null +++ b/scripts/prepare-site.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Download released API docs and prepare the changelog for the Zola site.""" + +import argparse +import hashlib +import io +import json +import re +import shutil +import tempfile +import urllib.request +import zipfile +from pathlib import Path, PurePosixPath + + +PACKAGES = {"jtest": "Pure Jo", "jtest-python": "Python", "jtest-ruby": "Ruby"} +ASSET_BASE = "https://github.com/typescope/jtest/releases/download" + + +def read_url(url): + with urllib.request.urlopen(url, timeout=60) as response: + return response.read() + + +def unpack_docs(archive, destination, package, version): + prefix = PurePosixPath(package, version) + with zipfile.ZipFile(io.BytesIO(archive)) as zipped: + for member in zipped.namelist(): + path = PurePosixPath(member) + if ".." in path.parts or "\\" in member or not path.is_relative_to(prefix): + raise ValueError(f"unexpected API archive path: {member}") + for name in ("index.html", "data.js"): + if f"{prefix}/{name}" not in zipped.namelist(): + raise ValueError(f"{package} {version}: API archive is missing {name}") + zipped.extractall(destination) + + +def prepare_site(root, asset_base=ASSET_BASE): + docs = root / "docs" + versions = [ + json.loads(line)["version"] + for line in (docs / "api-versions.jsonl").read_text().splitlines() + if line.strip() + ] + if not versions or any(not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", v) for v in versions): + raise ValueError("api-versions.jsonl must list stable release versions") + if len(set(versions)) != len(versions): + raise ValueError("api-versions.jsonl contains a duplicate version") + latest = max(versions, key=lambda v: tuple(map(int, v.split(".")))) + + # Build in a temporary directory so a failed download cannot publish partial docs. + with tempfile.TemporaryDirectory() as temporary: + staging = Path(temporary) + libraries = [] + for package, platform in PACKAGES.items(): + for version in versions: + name = f"{package}-api-docs-v{version}.zip" + url = f"{asset_base.rstrip('/')}/v{version}/{name}" + archive = read_url(url) + checksum = read_url(url + ".sha512").decode().split() + if not checksum or hashlib.sha512(archive).hexdigest() != checksum[0]: + raise ValueError(f"checksum mismatch: {url}") + unpack_docs(archive, staging, package, version) + print(f"{package} {version}: /api/{package}/{version}/") + + target = f"../{latest}/" + alias = staging / package / "latest" + alias.mkdir() + (alias / "index.html").write_text( + '' + f'{package} API' + f'' + f'' + f'Read {package} {latest} API documentation\n' + ) + libraries.append({ + "name": package, "platform": platform, "version": latest, + "path": f"api/{package}/latest/", + }) + + destination = docs / "static" / "api" + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(staging, destination) + + (docs / "api-docs.json").write_text(json.dumps({"libraries": libraries}, indent=2) + "\n") + # Nest the changelog headings below the page's single h1. + changelog = re.sub(r"(?m)^(#{1,5}) ", r"#\1 ", (root / "CHANGELOG.md").read_text()) + (docs / "changelog.md").write_text(changelog) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--asset-base", default=ASSET_BASE) + args = parser.parse_args() + prepare_site(Path("."), args.asset_base) diff --git a/scripts/test-docs.py b/scripts/test-docs.py new file mode 100644 index 0000000..8891fcf --- /dev/null +++ b/scripts/test-docs.py @@ -0,0 +1,106 @@ +"""Exercise release archives, version selection, and failed site preparation.""" + +import contextlib +import hashlib +import importlib.util +import io +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + + +def load_script(name): + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +package = load_script("package-api-docs") +site = load_script("prepare-site") + + +class DocsTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.assets = self.root / "releases" + (self.root / "docs").mkdir() + (self.root / "README.md").write_text("# jtest\n\nRelease introduction.\n") + (self.root / "CHANGELOG.md").write_text("# Changelog\n\n## [0.10.0]\n\n### Fixed\n\n- A fix.\n") + versions = ["0.2.0", "0.10.0"] + (self.root / "docs/api-versions.jsonl").write_text( + "".join(json.dumps({"version": version}) + "\n" for version in versions) + ) + for version in versions: + for module in package.PACKAGES: + source = self.root / ".build" / module / "doc" + source.mkdir(parents=True, exist_ok=True) + (source / "index.html").write_text("API") + data = {"meta": {"version": version}, "home": (self.root / "README.md").read_text()} + (source / "data.js").write_text("var JO_DOC_DATA=" + json.dumps(data)) + (source / "assets").mkdir(exist_ok=True) + (source / "assets/app.js").write_text("// doc app") + package.package_docs(version, self.assets / f"v{version}", self.root) + + def test_release_archives_contain_readme_and_complete_doc_tree(self): + for name in package.PACKAGES.values(): + archive = self.assets / "v0.2.0" / f"{name}-api-docs-v0.2.0.zip" + digest = archive.with_name(archive.name + ".sha512").read_text().split()[0] + self.assertEqual(digest, hashlib.sha512(archive.read_bytes()).hexdigest()) + with zipfile.ZipFile(archive) as zipped: + self.assertIn(f"{name}/0.2.0/assets/app.js", zipped.namelist()) + data = json.loads(zipped.read(f"{name}/0.2.0/data.js").split(b"=", 1)[1]) + self.assertEqual(data["home"], (self.root / "README.md").read_text()) + + def test_latest_is_semantic_and_old_versions_remain_accessible(self): + site.prepare_site(self.root, self.assets.as_uri()) + metadata = json.loads((self.root / "docs/api-docs.json").read_text()) + self.assertEqual(len(metadata["libraries"]), 3) + for library in metadata["libraries"]: + self.assertEqual(library["version"], "0.10.0") + base = self.root / "docs/static/api" / library["name"] + self.assertTrue((base / "0.2.0/index.html").is_file()) + self.assertTrue((base / "0.10.0/index.html").is_file()) + self.assertIn("../0.10.0/", (base / "latest/index.html").read_text()) + self.assertIn("location.hash", (base / "latest/index.html").read_text()) + self.assertIn("## Changelog", (self.root / "docs/changelog.md").read_text()) + + def test_corrupt_download_preserves_existing_site(self): + destination = self.root / "docs/static/api" + destination.mkdir(parents=True) + (destination / "existing.html").write_text("published docs") + archive = self.assets / "v0.2.0/jtest-api-docs-v0.2.0.zip" + archive.write_bytes(archive.read_bytes() + b"corrupted") + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + site.prepare_site(self.root, self.assets.as_uri()) + self.assertEqual((destination / "existing.html").read_text(), "published docs") + + def test_archive_rejects_paths_outside_its_package_and_version(self): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as zipped: + zipped.writestr("jtest/0.2.0/../../escape.html", "unexpected") + with self.assertRaisesRegex(ValueError, "unexpected API archive path"): + site.unpack_docs(archive.getvalue(), self.root / "unpacked", "jtest", "0.2.0") + + def test_missing_doc_files_fail_before_extraction(self): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as zipped: + zipped.writestr("jtest/0.2.0/index.html", "API") + with self.assertRaisesRegex(ValueError, "missing data.js"): + site.unpack_docs(archive.getvalue(), self.root / "unpacked", "jtest", "0.2.0") + + def test_packaging_rejects_wrong_version_and_stale_readme(self): + with self.assertRaisesRegex(ValueError, "do not match version"): + package.package_docs("0.2.0", self.root / "output", self.root) + (self.root / "README.md").write_text("Updated introduction") + with self.assertRaisesRegex(ValueError, "regenerate docs with --readme"): + package.package_docs("0.10.0", self.root / "output", self.root) + + +if __name__ == "__main__": + with contextlib.redirect_stdout(io.StringIO()): + unittest.main() From f3dfcb69f86c9591675410cf019a7bccef22fc6b Mon Sep 17 00:00:00 2001 From: Fengyun Liu Date: Sun, 18 Oct 2026 21:23:16 +0200 Subject: [PATCH 2/5] Unify API docs and link directly from site navigation --- .github/workflows/ci.yml | 11 ++--- CHANGELOG.md | 4 +- RELEASE.md | 64 ++++++++++++++-------------- docs/static/style.css | 20 +-------- docs/templates/index.html | 28 +++--------- jo.toml | 4 +- scripts/package-api-docs.py | 51 +++++++++++----------- scripts/prepare-site.py | 53 ++++++++++------------- scripts/test-docs.py | 85 ++++++++++++++++++++----------------- 9 files changed, 142 insertions(+), 178 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60a2626..95906f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,11 @@ jobs: jo package core jo package python jo package ruby - - name: Build API documentation archives + - name: Build unified API documentation archive run: | - jo doc core - jo doc python - jo doc ruby version=$(python3 -c 'import tomllib; print(tomllib.load(open("jo.toml", "rb"))["module"]["core"]["package"]["version"])') - python3 scripts/package-api-docs.py "$version" --output "$RUNNER_TEMP/jtest-api-docs" + jo compile --doc --use-runtime-api python --title jtest --project-version "$version" \ + --readme README.md --out "$RUNNER_TEMP/jtest-api" \ + src/core/*.jo src/runner/*.jo src/python/*.jo + python3 scripts/package-api-docs.py "$version" --source "$RUNNER_TEMP/jtest-api" \ + --output "$RUNNER_TEMP/jtest-api-docs" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9458516..5af51cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ ### Added -- A GitHub Pages site with the changelog and latest API documentation for all - three packages. Earlier API versions remain available at versioned URLs. +- A GitHub Pages site with the changelog and unified API documentation for + assertions, suites, and both runners. Earlier versions remain at versioned URLs. - Release API documentation archives with the README as their introduction. ### Fixed diff --git a/RELEASE.md b/RELEASE.md index 82cb515..d1d44ab 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -13,8 +13,8 @@ in `typescope/jtest`. Their registrations in `typescope/packages` use namespace dependencies; each runner must depend only on the matching core version. 5. Verify every `.sha512` file from its `.build//release/` directory. 6. Commit the reviewed sources, wait for green CI, tag that commit `vVERSION`, - and publish the `.joy`, source ZIP, API documentation ZIP, and their checksums - for every package. + and publish each package's `.joy`, source ZIP, and checksums, plus the unified + API documentation ZIP and its checksum. 7. Once registrations are merged, trigger `sync-releases.yml` in `typescope/packages` and verify all three versions at `pkg.jo-lang.org`. @@ -24,59 +24,60 @@ until all three versions are available from the public registry. ## API documentation assets -Generate documentation from the same clean, merged release commit as the -packages, using the CI compiler. All three modules use `README.md` as the API -documentation home page. Set `VERSION` to the package version in `jo.toml`: +Generate one API site from the same clean, merged release commit as the +packages, using the CI compiler. Compile the core and shared runner sources +together, with `README.md` as the home page. Python host helpers supply the +private implementation names needed for type checking; the public runner API +is shared with Ruby and appears only once. + +Set `VERSION` to the package version in `jo.toml`: ```sh VERSION=0.2.0 -jo doc core -jo doc python -jo doc ruby -python3 scripts/package-api-docs.py "$VERSION" --output "/tmp/jtest-api-release/v$VERSION" +API_DIR="/tmp/jtest-api-v$VERSION" +jo compile --doc --use-runtime-api python --title jtest --project-version "$VERSION" \ + --readme README.md --out "$API_DIR" \ + src/core/*.jo src/runner/*.jo src/python/*.jo +python3 scripts/package-api-docs.py "$VERSION" --source "$API_DIR" \ + --output "/tmp/jtest-docs-release/v$VERSION" ``` -The script checks that the generated version and README match the checkout. -It produces these archives and a `.sha512` file for each: - -- `jtest-api-docs-vVERSION.zip` -- `jtest-python-api-docs-vVERSION.zip` -- `jtest-ruby-api-docs-vVERSION.zip` - -Each archive contains `//index.html`, `data.js`, and assets. -Verify the checksums from the output directory: +The script checks the version, README, and presence of both the core and runner +API. It produces `jtest-docs-vVERSION.zip` and its `.sha512` file. The archive +contains `/index.html`, `data.js`, and assets. Verify the checksum from +the output directory: ```sh -(cd "/tmp/jtest-api-release/v$VERSION" && sha512sum --check *.sha512) +(cd "/tmp/jtest-docs-release/v$VERSION" && sha512sum --check *.sha512) ``` -Include all six files when creating the GitHub release alongside the package +Include both files when creating the GitHub release alongside the package assets. If the release already exists, add the new documentation assets with: ```sh -gh release upload "v$VERSION" "/tmp/jtest-api-release/v$VERSION/"* \ +gh release upload "v$VERSION" "/tmp/jtest-docs-release/v$VERSION/"* \ --repo typescope/jtest ``` Do not use `--clobber`. To add docs for an older release, check out its published -tag in a separate directory, use that release's compiler, and add the same -`--readme README.md` and package `--title` doc options if they are missing. -Generate and package from those released sources, including their original -README. Do not generate an older version's API docs from current `main`. +tag in a separate directory and use that release's compiler. Run the doc +compiler command from that checkout with its original README, then invoke +the current `package-api-docs.py` by its absolute path. Do not generate an +older version's API docs from current `main`. ## GitHub Pages After uploading the API assets, append `{"version": "VERSION"}` to `docs/api-versions.jsonl` in a PR. Keep all existing entries: the site downloads and verifies the archives for every listed version on every build. Only append -a version once its three API archives and checksums are available in the release. +a version once its unified API archive and checksum are available in the release. -The site shows only the latest version per package. Documentation URLs are: +The site links to the latest unified API documentation. Documentation URLs are: -- `https://typescope.github.io/jtest/api//latest/` -- `https://typescope.github.io/jtest/api///` +- `https://typescope.github.io/jtest/api/latest/` +- `https://typescope.github.io/jtest/api//` -The latest aliases preserve API symbol links, and previous version paths remain +The latest alias preserves API symbol links, and previous version paths remain accessible when a new release is added. The changelog is rendered directly from the repository's `CHANGELOG.md`. @@ -95,5 +96,4 @@ python3 scripts/prepare-site.py zola --root docs serve ``` -`prepare-site.py` regenerates ignored site data and the downloaded API tree; -generated `.build/` documentation is only read when packaging release assets. +`prepare-site.py` regenerates ignored site data and the downloaded API tree. diff --git a/docs/static/style.css b/docs/static/style.css index 081a003..1160666 100644 --- a/docs/static/style.css +++ b/docs/static/style.css @@ -21,7 +21,8 @@ code { font-family: "SFMono-Regular", Consolas, monospace; font-size: .91em; } .site-header { display: flex; align-items: center; justify-content: space-between; padding: 24px 0; border-bottom: 1px solid var(--line); } .wordmark { font-size: 24px; font-weight: 750; letter-spacing: -1px; text-decoration: none; color: var(--ink); } .wordmark span { color: var(--accent); } -nav, footer div { display: flex; gap: 24px; } +nav, footer div { display: flex; align-items: center; gap: 24px; } +.github-link { display: inline-flex; align-items: center; } nav a, footer a { font-size: 14px; text-decoration: none; } .skip-link { position: absolute; left: 16px; top: -80px; padding: 10px; background: var(--paper); } .skip-link:focus { top: 12px; } @@ -32,18 +33,6 @@ h1 { margin: 0 0 16px; font-size: clamp(48px, 8vw, 72px); line-height: 1; letter .intro-detail { max-width: 540px; color: var(--muted); margin: 0; } section + section { border-top: 1px solid var(--line); padding: 34px 0 40px; } h2 { margin: 0 0 12px; font-size: 24px; letter-spacing: -.5px; } -.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } -.section-note { white-space: nowrap; font-size: 12px; font-weight: 600; color: var(--accent); background: var(--wash); padding: 4px 10px; border-radius: 4px; } -#api > p { color: var(--muted); margin: 0 0 22px; } -table { border-collapse: collapse; width: 100%; margin: 24px 0 18px; text-align: left; } -th, td { padding: 16px 12px; border-bottom: 1px solid var(--line); } -th:first-child { padding-left: 0; } -thead th { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } -tbody th { font-weight: 600; } -td:last-child { text-align: right; padding-right: 0; } -thead th:last-child { text-align: right; padding-right: 0; } -td a { font-size: 14px; font-weight: 600; text-decoration: none; } -#api .version-note { font-size: 13px; margin-bottom: 0; } .changelog h3 { font-size: 19px; font-weight: 650; margin: 30px 0 12px; } .changelog h4 { font-size: 14px; color: var(--muted); margin: 20px 0 8px; } .changelog ul { padding-left: 22px; margin: 0 0 20px; } @@ -57,11 +46,6 @@ footer p { margin: 0; } nav a { font-size: 13px; } .intro { padding: 48px 0 36px; } .description { font-size: 20px; } - .section-heading { align-items: flex-start; } - .section-heading h2 { font-size: 22px; } - th, td { padding: 14px 6px; font-size: 13px; } - thead th { font-size: 10px; } - .version-note code { overflow-wrap: anywhere; } footer div { gap: 16px; } } @media (prefers-color-scheme: dark) { diff --git a/docs/templates/index.html b/docs/templates/index.html index b77849f..5f5e9b2 100644 --- a/docs/templates/index.html +++ b/docs/templates/index.html @@ -12,9 +12,12 @@
@@ -24,27 +27,6 @@

jtest

A shared testing API, with Python and Ruby runners.

Declare suites, collect assertion failures, and run independent tests in parallel.

-
-
-

API documentation

- Latest release -
-

Getting started, installation, and API reference for each package.

- {% set api = load_data(path="api-docs.json") %} - - - - {% for library in api.libraries %} - - - - - - {% endfor %} - -
PackagePlatformDocumentation
{{ library.name }}{{ library.platform }}v{{ library.version }}
-

Earlier docs remain at api/<package>/<version>/.

-
{% set changelog = load_data(path="changelog.md", format="plain") %} {{ changelog | markdown | safe }} diff --git a/jo.toml b/jo.toml index d3c9fdb..3a5c84f 100644 --- a/jo.toml +++ b/jo.toml @@ -5,7 +5,7 @@ kind = "lib" platform = "pure" src = ["src/core/"] compile-options = ["--test-pickling", "--check-tree"] -doc-options = ["--title", "jtest", "--readme", "README.md"] +doc-options = ["--title", "jtest"] [module.core.package] name = "jtest" @@ -18,7 +18,6 @@ enable-ffi = true src = ["src/runner/", "src/python/"] modules = ["core"] compile-options = ["--test-pickling", "--check-tree"] -doc-options = ["--title", "jtest-python", "--readme", "README.md"] [module.python.package] name = "jtest-python" @@ -31,7 +30,6 @@ enable-ffi = true src = ["src/runner/", "src/ruby/"] modules = ["core"] compile-options = ["--test-pickling", "--check-tree"] -doc-options = ["--title", "jtest-ruby", "--readme", "README.md"] [module.ruby.package] name = "jtest-ruby" diff --git a/scripts/package-api-docs.py b/scripts/package-api-docs.py index 454b23a..3f64d03 100644 --- a/scripts/package-api-docs.py +++ b/scripts/package-api-docs.py @@ -9,44 +9,41 @@ from pathlib import Path -PACKAGES = {"core": "jtest", "python": "jtest-python", "ruby": "jtest-ruby"} - - -def package_docs(version, output, root=Path(".")): +def package_docs(version, source, output, readme=Path("README.md")): if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): raise ValueError(f"expected a release version such as 0.2.0, got {version!r}") - sources = [] - for module, package in PACKAGES.items(): - source = root / ".build" / module / "doc" - for name in ("index.html", "data.js"): - if not (source / name).is_file(): - raise ValueError(f"missing {source / name}; run jo doc {module}") - data = json.loads((source / "data.js").read_text().split("=", 1)[1].rstrip(";\n")) - if data["meta"].get("version") != version: - raise ValueError(f"{source}: generated docs do not match version {version}") - if data.get("home") != (root / "README.md").read_text(): - raise ValueError(f"{source}: regenerate docs with --readme README.md") - sources.append((package, source)) + for name in ("index.html", "data.js"): + if not (source / name).is_file(): + raise ValueError(f"missing generated documentation: {source / name}") + data = json.loads((source / "data.js").read_text().split("=", 1)[1].rstrip(";\n")) + if data["meta"].get("version") != version: + raise ValueError(f"{source}: generated docs do not match version {version}") + if data.get("home") != readme.read_text(): + raise ValueError(f"{source}: regenerate docs with --readme README.md") + # Both parts must be generated together so namespace navigation and search agree. + symbols = data.get("symbols", {}) + if not all(name in symbols for name in ("jtest.Testing", "jtest.Runner")): + raise ValueError(f"{source}: generate core and shared runner docs together") output.mkdir(parents=True, exist_ok=True) - for package, source in sources: - archive = output / f"{package}-api-docs-v{version}.zip" - with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zipped: - for path in sorted(source.rglob("*")): - if path.is_file(): - target = Path(package) / version / path.relative_to(source) - zipped.write(path, target.as_posix()) - digest = hashlib.sha512(archive.read_bytes()).hexdigest() - archive.with_name(archive.name + ".sha512").write_text(f"{digest} {archive.name}\n") - print(archive) + archive = output / f"jtest-docs-v{version}.zip" + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zipped: + for path in sorted(source.rglob("*")): + if path.is_file(): + target = Path(version) / path.relative_to(source) + zipped.write(path, target.as_posix()) + digest = hashlib.sha512(archive.read_bytes()).hexdigest() + archive.with_name(archive.name + ".sha512").write_text(f"{digest} {archive.name}\n") + print(archive) if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("version") + parser.add_argument("--source", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() try: - package_docs(args.version, args.output) + package_docs(args.version, args.source, args.output) except ValueError as error: parser.exit(1, f"{error}\n") diff --git a/scripts/prepare-site.py b/scripts/prepare-site.py index b700b96..bfa2f41 100644 --- a/scripts/prepare-site.py +++ b/scripts/prepare-site.py @@ -13,7 +13,6 @@ from pathlib import Path, PurePosixPath -PACKAGES = {"jtest": "Pure Jo", "jtest-python": "Python", "jtest-ruby": "Ruby"} ASSET_BASE = "https://github.com/typescope/jtest/releases/download" @@ -22,8 +21,8 @@ def read_url(url): return response.read() -def unpack_docs(archive, destination, package, version): - prefix = PurePosixPath(package, version) +def unpack_docs(archive, destination, version): + prefix = PurePosixPath(version) with zipfile.ZipFile(io.BytesIO(archive)) as zipped: for member in zipped.namelist(): path = PurePosixPath(member) @@ -31,7 +30,7 @@ def unpack_docs(archive, destination, package, version): raise ValueError(f"unexpected API archive path: {member}") for name in ("index.html", "data.js"): if f"{prefix}/{name}" not in zipped.namelist(): - raise ValueError(f"{package} {version}: API archive is missing {name}") + raise ValueError(f"{version}: API archive is missing {name}") zipped.extractall(destination) @@ -51,39 +50,33 @@ def prepare_site(root, asset_base=ASSET_BASE): # Build in a temporary directory so a failed download cannot publish partial docs. with tempfile.TemporaryDirectory() as temporary: staging = Path(temporary) - libraries = [] - for package, platform in PACKAGES.items(): - for version in versions: - name = f"{package}-api-docs-v{version}.zip" - url = f"{asset_base.rstrip('/')}/v{version}/{name}" - archive = read_url(url) - checksum = read_url(url + ".sha512").decode().split() - if not checksum or hashlib.sha512(archive).hexdigest() != checksum[0]: - raise ValueError(f"checksum mismatch: {url}") - unpack_docs(archive, staging, package, version) - print(f"{package} {version}: /api/{package}/{version}/") + for version in versions: + name = f"jtest-docs-v{version}.zip" + url = f"{asset_base.rstrip('/')}/v{version}/{name}" + archive = read_url(url) + checksum = read_url(url + ".sha512").decode().split() + if not checksum or hashlib.sha512(archive).hexdigest() != checksum[0]: + raise ValueError(f"checksum mismatch: {url}") + unpack_docs(archive, staging, version) + print(f"jtest {version}: /api/{version}/") - target = f"../{latest}/" - alias = staging / package / "latest" - alias.mkdir() - (alias / "index.html").write_text( - '' - f'{package} API' - f'' - f'' - f'Read {package} {latest} API documentation\n' - ) - libraries.append({ - "name": package, "platform": platform, "version": latest, - "path": f"api/{package}/latest/", - }) + target = f"../{latest}/" + alias = staging / "latest" + alias.mkdir() + (alias / "index.html").write_text( + '' + 'jtest API' + f'' + f'' + f'Read jtest {latest} API documentation\n' + ) destination = docs / "static" / "api" if destination.exists(): shutil.rmtree(destination) shutil.copytree(staging, destination) - (docs / "api-docs.json").write_text(json.dumps({"libraries": libraries}, indent=2) + "\n") + (docs / "api-docs.json").write_text(json.dumps({"version": latest, "path": "api/latest/"}) + "\n") # Nest the changelog headings below the page's single h1. changelog = re.sub(r"(?m)^(#{1,5}) ", r"#\1 ", (root / "CHANGELOG.md").read_text()) (docs / "changelog.md").write_text(changelog) diff --git a/scripts/test-docs.py b/scripts/test-docs.py index 8891fcf..a5c7832 100644 --- a/scripts/test-docs.py +++ b/scripts/test-docs.py @@ -1,4 +1,4 @@ -"""Exercise release archives, version selection, and failed site preparation.""" +"""Exercise unified release archives, version selection, and failed site preparation.""" import contextlib import hashlib @@ -28,77 +28,86 @@ def setUp(self): self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) self.assets = self.root / "releases" + self.source = self.root / "generated-docs" + self.readme = self.root / "README.md" (self.root / "docs").mkdir() - (self.root / "README.md").write_text("# jtest\n\nRelease introduction.\n") + self.readme.write_text("# jtest\n\nRelease introduction.\n") (self.root / "CHANGELOG.md").write_text("# Changelog\n\n## [0.10.0]\n\n### Fixed\n\n- A fix.\n") versions = ["0.2.0", "0.10.0"] (self.root / "docs/api-versions.jsonl").write_text( "".join(json.dumps({"version": version}) + "\n" for version in versions) ) + self.source.mkdir() + (self.source / "index.html").write_text("API") + (self.source / "assets").mkdir() + (self.source / "assets/app.js").write_text("// doc app") for version in versions: - for module in package.PACKAGES: - source = self.root / ".build" / module / "doc" - source.mkdir(parents=True, exist_ok=True) - (source / "index.html").write_text("API") - data = {"meta": {"version": version}, "home": (self.root / "README.md").read_text()} - (source / "data.js").write_text("var JO_DOC_DATA=" + json.dumps(data)) - (source / "assets").mkdir(exist_ok=True) - (source / "assets/app.js").write_text("// doc app") - package.package_docs(version, self.assets / f"v{version}", self.root) - - def test_release_archives_contain_readme_and_complete_doc_tree(self): - for name in package.PACKAGES.values(): - archive = self.assets / "v0.2.0" / f"{name}-api-docs-v0.2.0.zip" - digest = archive.with_name(archive.name + ".sha512").read_text().split()[0] - self.assertEqual(digest, hashlib.sha512(archive.read_bytes()).hexdigest()) - with zipfile.ZipFile(archive) as zipped: - self.assertIn(f"{name}/0.2.0/assets/app.js", zipped.namelist()) - data = json.loads(zipped.read(f"{name}/0.2.0/data.js").split(b"=", 1)[1]) - self.assertEqual(data["home"], (self.root / "README.md").read_text()) + data = { + "meta": {"version": version}, "home": self.readme.read_text(), + "symbols": {"jtest.Testing": {}, "jtest.Runner": {}}, + } + (self.source / "data.js").write_text("var JO_DOC_DATA=" + json.dumps(data)) + package.package_docs(version, self.source, self.assets / f"v{version}", self.readme) + + def test_archive_contains_readme_core_runner_and_complete_doc_tree(self): + archive = self.assets / "v0.2.0/jtest-docs-v0.2.0.zip" + digest = archive.with_name(archive.name + ".sha512").read_text().split()[0] + self.assertEqual(digest, hashlib.sha512(archive.read_bytes()).hexdigest()) + with zipfile.ZipFile(archive) as zipped: + self.assertIn("0.2.0/assets/app.js", zipped.namelist()) + data = json.loads(zipped.read("0.2.0/data.js").split(b"=", 1)[1]) + self.assertEqual(data["home"], self.readme.read_text()) + self.assertIn("jtest.Testing", data["symbols"]) + self.assertIn("jtest.Runner", data["symbols"]) def test_latest_is_semantic_and_old_versions_remain_accessible(self): site.prepare_site(self.root, self.assets.as_uri()) metadata = json.loads((self.root / "docs/api-docs.json").read_text()) - self.assertEqual(len(metadata["libraries"]), 3) - for library in metadata["libraries"]: - self.assertEqual(library["version"], "0.10.0") - base = self.root / "docs/static/api" / library["name"] - self.assertTrue((base / "0.2.0/index.html").is_file()) - self.assertTrue((base / "0.10.0/index.html").is_file()) - self.assertIn("../0.10.0/", (base / "latest/index.html").read_text()) - self.assertIn("location.hash", (base / "latest/index.html").read_text()) + self.assertEqual(metadata, {"version": "0.10.0", "path": "api/latest/"}) + base = self.root / "docs/static/api" + self.assertTrue((base / "0.2.0/index.html").is_file()) + self.assertTrue((base / "0.10.0/index.html").is_file()) + self.assertIn("../0.10.0/", (base / "latest/index.html").read_text()) + self.assertIn("location.hash", (base / "latest/index.html").read_text()) self.assertIn("## Changelog", (self.root / "docs/changelog.md").read_text()) def test_corrupt_download_preserves_existing_site(self): destination = self.root / "docs/static/api" destination.mkdir(parents=True) (destination / "existing.html").write_text("published docs") - archive = self.assets / "v0.2.0/jtest-api-docs-v0.2.0.zip" + archive = self.assets / "v0.2.0/jtest-docs-v0.2.0.zip" archive.write_bytes(archive.read_bytes() + b"corrupted") with self.assertRaisesRegex(ValueError, "checksum mismatch"): site.prepare_site(self.root, self.assets.as_uri()) self.assertEqual((destination / "existing.html").read_text(), "published docs") - def test_archive_rejects_paths_outside_its_package_and_version(self): + def test_archive_rejects_paths_outside_its_version(self): archive = io.BytesIO() with zipfile.ZipFile(archive, "w") as zipped: - zipped.writestr("jtest/0.2.0/../../escape.html", "unexpected") + zipped.writestr("0.2.0/../../escape.html", "unexpected") with self.assertRaisesRegex(ValueError, "unexpected API archive path"): - site.unpack_docs(archive.getvalue(), self.root / "unpacked", "jtest", "0.2.0") + site.unpack_docs(archive.getvalue(), self.root / "unpacked", "0.2.0") def test_missing_doc_files_fail_before_extraction(self): archive = io.BytesIO() with zipfile.ZipFile(archive, "w") as zipped: - zipped.writestr("jtest/0.2.0/index.html", "API") + zipped.writestr("0.2.0/index.html", "API") with self.assertRaisesRegex(ValueError, "missing data.js"): - site.unpack_docs(archive.getvalue(), self.root / "unpacked", "jtest", "0.2.0") + site.unpack_docs(archive.getvalue(), self.root / "unpacked", "0.2.0") def test_packaging_rejects_wrong_version_and_stale_readme(self): with self.assertRaisesRegex(ValueError, "do not match version"): - package.package_docs("0.2.0", self.root / "output", self.root) - (self.root / "README.md").write_text("Updated introduction") + package.package_docs("0.2.0", self.source, self.root / "output", self.readme) + self.readme.write_text("Updated introduction") with self.assertRaisesRegex(ValueError, "regenerate docs with --readme"): - package.package_docs("0.10.0", self.root / "output", self.root) + package.package_docs("0.10.0", self.source, self.root / "output", self.readme) + + def test_packaging_requires_both_core_and_runner(self): + data = json.loads((self.source / "data.js").read_text().split("=", 1)[1]) + del data["symbols"]["jtest.Runner"] + (self.source / "data.js").write_text("var JO_DOC_DATA=" + json.dumps(data)) + with self.assertRaisesRegex(ValueError, "core and shared runner docs together"): + package.package_docs("0.10.0", self.source, self.root / "output", self.readme) if __name__ == "__main__": From c2abbcdf3ba65549f185249b47ff844562314bae Mon Sep 17 00:00:00 2001 From: Fengyun Liu Date: Sun, 18 Oct 2026 21:26:40 +0200 Subject: [PATCH 3/5] Serve versioned API docs directly with a latest-version root redirect --- .github/workflows/docs.yml | 16 +----------- .gitignore | 3 --- CHANGELOG.md | 5 ++-- README.md | 2 +- RELEASE.md | 19 +++++++------- docs/config.toml | 9 ------- docs/content/_index.md | 3 --- docs/static/style.css | 53 -------------------------------------- docs/templates/index.html | 40 ---------------------------- scripts/prepare-site.py | 31 ++++++++++------------ scripts/test-docs.py | 18 +++++++------ 11 files changed, 39 insertions(+), 160 deletions(-) delete mode 100644 docs/config.toml delete mode 100644 docs/content/_index.md delete mode 100644 docs/static/style.css delete mode 100644 docs/templates/index.html diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cd1844b..af76d04 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,8 +7,6 @@ on: - 'docs/**' - 'scripts/*docs.py' - 'scripts/prepare-site.py' - - 'README.md' - - 'CHANGELOG.md' - '.github/workflows/docs.yml' pull_request: branches: [main] @@ -16,8 +14,6 @@ on: - 'docs/**' - 'scripts/*docs.py' - 'scripts/prepare-site.py' - - 'README.md' - - 'CHANGELOG.md' - '.github/workflows/docs.yml' workflow_dispatch: @@ -28,9 +24,6 @@ concurrency: group: pages-${{ github.ref }} cancel-in-progress: true -env: - ZOLA_VERSION: '0.22.1' - jobs: build: runs-on: ubuntu-latest @@ -41,15 +34,8 @@ jobs: with: python-version: '3.12' - run: python3 scripts/test-docs.py - - name: Download released API documentation and prepare changelog + - name: Download released API documentation and build site run: python3 scripts/prepare-site.py - - name: Install Zola - run: | - curl -fsSL "https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \ - | tar xz -C /usr/local/bin zola - - name: Build site - working-directory: docs - run: zola build - uses: actions/upload-pages-artifact@v4 if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' with: diff --git a/.gitignore b/.gitignore index 31c38f9..faab3b2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,3 @@ __pycache__/ *.jo-before.txt *.jo-after.txt docs/public/ -docs/static/api/ -docs/api-docs.json -docs/changelog.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af51cd..2dfc9fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ ### Added -- A GitHub Pages site with the changelog and unified API documentation for - assertions, suites, and both runners. Earlier versions remain at versioned URLs. +- Versioned API documentation on GitHub Pages, combining assertions, suites, + and both runners. The site root redirects to the latest release; earlier + versions remain available at their versioned URLs. - Release API documentation archives with the README as their introduction. ### Fixed diff --git a/README.md b/README.md index 2b10ede..da54eb3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A testing framework for Jo, with a shared API and Python and Ruby runners. -[API documentation and changelog](https://typescope.github.io/jtest/). +[API documentation](https://typescope.github.io/jtest/). | Package | Platform | Provides | | --- | --- | --- | diff --git a/RELEASE.md b/RELEASE.md index d1d44ab..99ed68f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -72,14 +72,14 @@ After uploading the API assets, append `{"version": "VERSION"}` to and verifies the archives for every listed version on every build. Only append a version once its unified API archive and checksum are available in the release. -The site links to the latest unified API documentation. Documentation URLs are: +The site root redirects to the latest listed version. Documentation URLs are: -- `https://typescope.github.io/jtest/api/latest/` -- `https://typescope.github.io/jtest/api//` +- `https://typescope.github.io/jtest/` → `https://typescope.github.io/jtest/0.2.0/` +- `https://typescope.github.io/jtest//` -The latest alias preserves API symbol links, and previous version paths remain -accessible when a new release is added. The changelog is rendered directly from -the repository's `CHANGELOG.md`. +The redirect preserves query strings and API symbol links. Previous version +paths remain accessible when a new release is added. The README in each API +archive is that version's home page. Set the repository's Pages source to **GitHub Actions** once. The `Docs` workflow checks PRs and deploys site changes merged to `main`. To republish manually: @@ -88,12 +88,13 @@ checks PRs and deploys site changes merged to `main`. To republish manually: gh workflow run docs.yml --repo typescope/jtest --ref main ``` -For a local preview, install Zola 0.22.1, then run: +For a local preview, run: ```sh python3 scripts/test-docs.py python3 scripts/prepare-site.py -zola --root docs serve +python3 -m http.server 8000 --directory docs/public ``` -`prepare-site.py` regenerates ignored site data and the downloaded API tree. +`prepare-site.py` builds the ignored `docs/public/` directory using Python's +standard library. It contains the root redirect and a directory for each version. diff --git a/docs/config.toml b/docs/config.toml deleted file mode 100644 index f943605..0000000 --- a/docs/config.toml +++ /dev/null @@ -1,9 +0,0 @@ -base_url = "https://typescope.github.io/jtest" -title = "jtest" -description = "A testing framework for Jo, with a shared API and Python and Ruby runners." -compile_sass = false -build_search_index = false -generate_feeds = false - -[extra] -github = "https://github.com/typescope/jtest" diff --git a/docs/content/_index.md b/docs/content/_index.md deleted file mode 100644 index ef6083b..0000000 --- a/docs/content/_index.md +++ /dev/null @@ -1,3 +0,0 @@ -+++ -template = "index.html" -+++ diff --git a/docs/static/style.css b/docs/static/style.css deleted file mode 100644 index 1160666..0000000 --- a/docs/static/style.css +++ /dev/null @@ -1,53 +0,0 @@ -:root { - color-scheme: light dark; - --paper: #fbfcfa; - --ink: #1c3028; - --muted: #65736b; - --line: #dce4dc; - --accent: #19664d; - --wash: #edf3ed; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - color: var(--ink); - background: var(--paper); - line-height: 1.6; -} -* { box-sizing: border-box; } -html { scroll-padding-top: 2rem; } -body { max-width: 880px; padding: 0 32px; margin: auto; } -a { color: var(--accent); text-underline-offset: 4px; } -a:hover { text-decoration-thickness: 2px; } -a:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; } -code { font-family: "SFMono-Regular", Consolas, monospace; font-size: .91em; } -.site-header { display: flex; align-items: center; justify-content: space-between; padding: 24px 0; border-bottom: 1px solid var(--line); } -.wordmark { font-size: 24px; font-weight: 750; letter-spacing: -1px; text-decoration: none; color: var(--ink); } -.wordmark span { color: var(--accent); } -nav, footer div { display: flex; align-items: center; gap: 24px; } -.github-link { display: inline-flex; align-items: center; } -nav a, footer a { font-size: 14px; text-decoration: none; } -.skip-link { position: absolute; left: 16px; top: -80px; padding: 10px; background: var(--paper); } -.skip-link:focus { top: 12px; } -.intro { padding: 68px 0 48px; } -.eyebrow { margin: 0 0 12px; text-transform: uppercase; font-size: 12px; letter-spacing: .16em; font-weight: 700; color: var(--accent); } -h1 { margin: 0 0 16px; font-size: clamp(48px, 8vw, 72px); line-height: 1; letter-spacing: -3px; font-weight: 750; } -.description { font-size: 22px; max-width: 520px; line-height: 1.5; margin: 0 0 12px; } -.intro-detail { max-width: 540px; color: var(--muted); margin: 0; } -section + section { border-top: 1px solid var(--line); padding: 34px 0 40px; } -h2 { margin: 0 0 12px; font-size: 24px; letter-spacing: -.5px; } -.changelog h3 { font-size: 19px; font-weight: 650; margin: 30px 0 12px; } -.changelog h4 { font-size: 14px; color: var(--muted); margin: 20px 0 8px; } -.changelog ul { padding-left: 22px; margin: 0 0 20px; } -.changelog li { padding-left: 4px; margin: 8px 0; } -.changelog code { overflow-wrap: anywhere; background: var(--wash); padding: 2px 4px; border-radius: 3px; } -footer { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; border-top: 1px solid var(--line); padding: 22px 0 32px; font-size: 13px; color: var(--muted); } -footer p { margin: 0; } -@media (max-width: 540px) { - body { padding: 0 20px; } - nav { gap: 16px; } - nav a { font-size: 13px; } - .intro { padding: 48px 0 36px; } - .description { font-size: 20px; } - footer div { gap: 16px; } -} -@media (prefers-color-scheme: dark) { - :root { --paper: #17231e; --ink: #e4ebe5; --muted: #a6b5aa; --line: #34473b; --accent: #91cfad; --wash: #24392d; } -} diff --git a/docs/templates/index.html b/docs/templates/index.html deleted file mode 100644 index 5f5e9b2..0000000 --- a/docs/templates/index.html +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - {{ config.title }} — Testing for Jo - - - - - -
-
-

Testing for Jo

-

jtest

-

A shared testing API, with Python and Ruby runners.

-

Declare suites, collect assertion failures, and run independent tests in parallel.

-
-
- {% set changelog = load_data(path="changelog.md", format="plain") %} - {{ changelog | markdown | safe }} -
-
- - - diff --git a/scripts/prepare-site.py b/scripts/prepare-site.py index bfa2f41..5795790 100644 --- a/scripts/prepare-site.py +++ b/scripts/prepare-site.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Download released API docs and prepare the changelog for the Zola site.""" +"""Build GitHub Pages from released API docs, redirecting the root to the latest.""" import argparse import hashlib @@ -58,29 +58,26 @@ def prepare_site(root, asset_base=ASSET_BASE): if not checksum or hashlib.sha512(archive).hexdigest() != checksum[0]: raise ValueError(f"checksum mismatch: {url}") unpack_docs(archive, staging, version) - print(f"jtest {version}: /api/{version}/") + print(f"jtest {version}: /{version}/") - target = f"../{latest}/" - alias = staging / "latest" - alias.mkdir() - (alias / "index.html").write_text( - '' - 'jtest API' - f'' - f'' - f'Read jtest {latest} API documentation\n' + target = f"{latest}/" + (staging / "index.html").write_text( + '\n\n\n\n' + '\n' + 'jtest API\n' + f'\n' + f'\n' + '\n\n' + f'Read jtest {latest} API documentation\n' + '\n\n' ) + (staging / ".nojekyll").touch() - destination = docs / "static" / "api" + destination = docs / "public" if destination.exists(): shutil.rmtree(destination) shutil.copytree(staging, destination) - (docs / "api-docs.json").write_text(json.dumps({"version": latest, "path": "api/latest/"}) + "\n") - # Nest the changelog headings below the page's single h1. - changelog = re.sub(r"(?m)^(#{1,5}) ", r"#\1 ", (root / "CHANGELOG.md").read_text()) - (docs / "changelog.md").write_text(changelog) - if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) diff --git a/scripts/test-docs.py b/scripts/test-docs.py index a5c7832..d8f63dd 100644 --- a/scripts/test-docs.py +++ b/scripts/test-docs.py @@ -32,7 +32,6 @@ def setUp(self): self.readme = self.root / "README.md" (self.root / "docs").mkdir() self.readme.write_text("# jtest\n\nRelease introduction.\n") - (self.root / "CHANGELOG.md").write_text("# Changelog\n\n## [0.10.0]\n\n### Fixed\n\n- A fix.\n") versions = ["0.2.0", "0.10.0"] (self.root / "docs/api-versions.jsonl").write_text( "".join(json.dumps({"version": version}) + "\n" for version in versions) @@ -62,17 +61,20 @@ def test_archive_contains_readme_core_runner_and_complete_doc_tree(self): def test_latest_is_semantic_and_old_versions_remain_accessible(self): site.prepare_site(self.root, self.assets.as_uri()) - metadata = json.loads((self.root / "docs/api-docs.json").read_text()) - self.assertEqual(metadata, {"version": "0.10.0", "path": "api/latest/"}) - base = self.root / "docs/static/api" + base = self.root / "docs/public" self.assertTrue((base / "0.2.0/index.html").is_file()) self.assertTrue((base / "0.10.0/index.html").is_file()) - self.assertIn("../0.10.0/", (base / "latest/index.html").read_text()) - self.assertIn("location.hash", (base / "latest/index.html").read_text()) - self.assertIn("## Changelog", (self.root / "docs/changelog.md").read_text()) + redirect = (base / "index.html").read_text() + self.assertIn('content="0;url=0.10.0/"', redirect) + self.assertIn('href="0.10.0/"', redirect) + self.assertIn("location.search + location.hash", redirect) + self.assertEqual( + {path.name for path in base.iterdir()}, + {"0.2.0", "0.10.0", "index.html", ".nojekyll"}, + ) def test_corrupt_download_preserves_existing_site(self): - destination = self.root / "docs/static/api" + destination = self.root / "docs/public" destination.mkdir(parents=True) (destination / "existing.html").write_text("published docs") archive = self.assets / "v0.2.0/jtest-docs-v0.2.0.zip" From 455a5341e8c61dd946b7c537b22b7adba1df3a9e Mon Sep 17 00:00:00 2001 From: Fengyun Liu Date: Sun, 18 Oct 2026 21:29:07 +0200 Subject: [PATCH 4/5] Document API website publication and live verification --- RELEASE.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 99ed68f..0adef7f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -17,6 +17,9 @@ in `typescope/jtest`. Their registrations in `typescope/packages` use namespace API documentation ZIP and its checksum. 7. Once registrations are merged, trigger `sync-releases.yml` in `typescope/packages` and verify all three versions at `pkg.jo-lang.org`. +8. Publish the API docs website: add the version to `docs/api-versions.jsonl` + in a PR, wait for its merge and the Pages deployment, then verify the live + root redirect and version URLs as described below. Never replace published artifacts or move a published tag. Package registration is a reviewed PR; leave merges to a maintainer. Do not switch downstream users @@ -65,13 +68,25 @@ compiler command from that checkout with its original README, then invoke the current `package-api-docs.py` by its absolute path. Do not generate an older version's API docs from current `main`. -## GitHub Pages +## Publish the API docs website After uploading the API assets, append `{"version": "VERSION"}` to `docs/api-versions.jsonl` in a PR. Keep all existing entries: the site downloads and verifies the archives for every listed version on every build. Only append a version once its unified API archive and checksum are available in the release. +After this manifest PR is merged to `main`, the `Docs` workflow downloads all +listed API archives and publishes them to GitHub Pages. Wait for the deployment +from that merge commit to succeed: + +```sh +gh run list --repo typescope/jtest --workflow docs.yml --branch main --limit 5 +gh run watch RUN_ID --repo typescope/jtest --exit-status +``` + +Use the run ID for the merged manifest commit. A successful PR build validates +the site; the deployment runs after merge. + The site root redirects to the latest listed version. Documentation URLs are: - `https://typescope.github.io/jtest/` → `https://typescope.github.io/jtest/0.2.0/` @@ -88,6 +103,21 @@ checks PRs and deploys site changes merged to `main`. To republish manually: gh workflow run docs.yml --repo typescope/jtest --ref main ``` +Wait for that run with the same commands above. Before announcing the docs, +open `https://typescope.github.io/jtest/` in a browser and confirm it redirects +to `/$VERSION/`. Check the versioned page, an API symbol link, and an earlier +version's URL. Verify the published doc files respond successfully: + +```sh +curl --fail --silent --show-error --output /dev/null \ + "https://typescope.github.io/jtest/$VERSION/index.html" +curl --fail --silent --show-error --output /dev/null \ + "https://typescope.github.io/jtest/$VERSION/data.js" +``` + +Website publication is complete once the Pages deployment and these live +checks pass. + For a local preview, run: ```sh From b2e32a449a39117510b5c9a0ec7a38747793128d Mon Sep 17 00:00:00 2001 From: Fengyun Liu Date: Sun, 18 Oct 2026 21:33:01 +0200 Subject: [PATCH 5/5] Use jtest.jo-lang.org for API documentation --- CHANGELOG.md | 2 +- README.md | 2 +- RELEASE.md | 20 +++++++++++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dfc9fd..9cfa50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Versioned API documentation on GitHub Pages, combining assertions, suites, +- Versioned API documentation at `jtest.jo-lang.org`, combining assertions, suites, and both runners. The site root redirects to the latest release; earlier versions remain available at their versioned URLs. - Release API documentation archives with the README as their introduction. diff --git a/README.md b/README.md index da54eb3..97ef690 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A testing framework for Jo, with a shared API and Python and Ruby runners. -[API documentation](https://typescope.github.io/jtest/). +[API documentation](https://jtest.jo-lang.org/). | Package | Platform | Provides | | --- | --- | --- | diff --git a/RELEASE.md b/RELEASE.md index 0adef7f..103fdfc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -89,30 +89,36 @@ the site; the deployment runs after merge. The site root redirects to the latest listed version. Documentation URLs are: -- `https://typescope.github.io/jtest/` → `https://typescope.github.io/jtest/0.2.0/` -- `https://typescope.github.io/jtest//` +- `https://jtest.jo-lang.org/` → `https://jtest.jo-lang.org/0.2.0/` +- `https://jtest.jo-lang.org//` The redirect preserves query strings and API symbol links. Previous version paths remain accessible when a new release is added. The README in each API archive is that version's home page. -Set the repository's Pages source to **GitHub Actions** once. The `Docs` workflow -checks PRs and deploys site changes merged to `main`. To republish manually: +Set the repository's Pages source to **GitHub Actions** and its custom domain +to **jtest.jo-lang.org** once. Configure DNS with a `CNAME` from +`jtest.jo-lang.org` to `typescope.github.io`, and enable **Enforce HTTPS** once +GitHub has issued the certificate. The custom domain is a repository Pages +setting for the Actions publishing workflow. + +The `Docs` workflow checks PRs and deploys site changes merged to `main`. +To republish manually: ```sh gh workflow run docs.yml --repo typescope/jtest --ref main ``` Wait for that run with the same commands above. Before announcing the docs, -open `https://typescope.github.io/jtest/` in a browser and confirm it redirects +open `https://jtest.jo-lang.org/` in a browser and confirm it redirects to `/$VERSION/`. Check the versioned page, an API symbol link, and an earlier version's URL. Verify the published doc files respond successfully: ```sh curl --fail --silent --show-error --output /dev/null \ - "https://typescope.github.io/jtest/$VERSION/index.html" + "https://jtest.jo-lang.org/$VERSION/index.html" curl --fail --silent --show-error --output /dev/null \ - "https://typescope.github.io/jtest/$VERSION/data.js" + "https://jtest.jo-lang.org/$VERSION/data.js" ``` Website publication is complete once the Pages deployment and these live