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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
57 changes: 57 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ jo.lock
__pycache__/
*.jo-before.txt
*.jo-after.txt
docs/public/
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
119 changes: 117 additions & 2 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<module>/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 `<version>/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/<version>/`

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.
2 changes: 2 additions & 0 deletions docs/api-versions.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"version": "0.1.0"}
{"version": "0.2.0"}
49 changes: 49 additions & 0 deletions scripts/package-api-docs.py
Original file line number Diff line number Diff line change
@@ -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")
86 changes: 86 additions & 0 deletions scripts/prepare-site.py
Original file line number Diff line number Diff line change
@@ -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(
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
'<title>jtest API</title>\n'
f'<meta http-equiv="refresh" content="0;url={target}">\n'
f'<script>location.replace("{target}" + location.search + location.hash)</script>\n'
'</head>\n<body>\n'
f'<a href="{target}">Read jtest {latest} API documentation</a>\n'
'</body>\n</html>\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)
Loading
Loading