diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 910e89b..95906f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,11 @@ jobs: jo package core jo package python jo package ruby + - name: Build unified API documentation archive + run: | + version=$(python3 -c 'import tomllib; print(tomllib.load(open("jo.toml", "rb"))["module"]["core"]["package"]["version"])') + 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/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..af76d04 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,57 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'scripts/*docs.py' + - 'scripts/prepare-site.py' + - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - 'docs/**' + - 'scripts/*docs.py' + - 'scripts/prepare-site.py' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: true + +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 build site + run: python3 scripts/prepare-site.py + - 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..faab3b2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ jo.lock __pycache__/ *.jo-before.txt *.jo-after.txt +docs/public/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 03df7c0..9cfa50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Added + +- 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. + ### Fixed - `Runner.runWithArgs` uses normal process exit with status 1 after reporting diff --git a/README.md b/README.md index 4947f0b..97ef690 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](https://jtest.jo-lang.org/). + | Package | Platform | Provides | | --- | --- | --- | | `jtest` | pure | Suite declarations, assertions, and assertion results | diff --git a/RELEASE.md b/RELEASE.md index 9902212..103fdfc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,15 +7,130 @@ 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 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`. +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 until all three versions are available from the public registry. + +## API documentation assets + +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 +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 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-docs-release/v$VERSION" && sha512sum --check *.sha512) +``` + +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-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 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`. + +## 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://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** 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://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://jtest.jo-lang.org/$VERSION/index.html" +curl --fail --silent --show-error --output /dev/null \ + "https://jtest.jo-lang.org/$VERSION/data.js" +``` + +Website publication is complete once the Pages deployment and these live +checks pass. + +For a local preview, run: + +```sh +python3 scripts/test-docs.py +python3 scripts/prepare-site.py +python3 -m http.server 8000 --directory docs/public +``` + +`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/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/scripts/package-api-docs.py b/scripts/package-api-docs.py new file mode 100644 index 0000000..3f64d03 --- /dev/null +++ b/scripts/package-api-docs.py @@ -0,0 +1,49 @@ +#!/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 + + +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}") + 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) + 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.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 new file mode 100644 index 0000000..5795790 --- /dev/null +++ b/scripts/prepare-site.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Build GitHub Pages from released API docs, redirecting the root to the latest.""" + +import argparse +import hashlib +import io +import json +import re +import shutil +import tempfile +import urllib.request +import zipfile +from pathlib import Path, PurePosixPath + + +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, version): + prefix = PurePosixPath(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"{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) + 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}: /{version}/") + + 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 / "public" + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(staging, destination) + + +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..d8f63dd --- /dev/null +++ b/scripts/test-docs.py @@ -0,0 +1,117 @@ +"""Exercise unified 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.source = self.root / "generated-docs" + self.readme = self.root / "README.md" + (self.root / "docs").mkdir() + self.readme.write_text("# jtest\n\nRelease introduction.\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: + 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()) + 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()) + 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/public" + destination.mkdir(parents=True) + (destination / "existing.html").write_text("published docs") + 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_version(self): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as zipped: + zipped.writestr("0.2.0/../../escape.html", "unexpected") + with self.assertRaisesRegex(ValueError, "unexpected API archive path"): + 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("0.2.0/index.html", "API") + with self.assertRaisesRegex(ValueError, "missing data.js"): + 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.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.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__": + with contextlib.redirect_stdout(io.StringIO()): + unittest.main()