diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68d610f..0c0dcfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,51 @@ on: - reopened - synchronize - labeled + - unlabeled push: branches: - main jobs: + docs-config: + name: Documentation version configuration + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Checkout trusted validator + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha || github.sha }} + path: trusted + persist-credentials: false + sparse-checkout: scripts/generate_versioned_api_docs.py + sparse-checkout-cone-mode: false + - name: Checkout candidate configuration + uses: actions/checkout@v4 + with: + path: candidate + persist-credentials: false + sparse-checkout: api-docs/versions.json + sparse-checkout-cone-mode: false + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Validate candidate configuration with trusted code + working-directory: trusted + run: | + python3 -I - <<'PY' + import importlib.util + from pathlib import Path + + spec = importlib.util.spec_from_file_location( + "docs", "scripts/generate_versioned_api_docs.py" + ) + docs = importlib.util.module_from_spec(spec) + spec.loader.exec_module(docs) + docs.load_versions(Path("../candidate/api-docs/versions.json")) + PY + docs: name: Versioned API documentation permissions: @@ -43,6 +83,29 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: python3 scripts/generate_versioned_api_docs.py + - name: Upload documentation preview + uses: actions/upload-artifact@v4 + with: + name: versioned-api-documentation + path: api-docs/_build/versioned/ + if-no-files-found: error + + docs-required: + name: Documentation checks + needs: [docs-config, docs] + if: always() + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Require successful validation and build + env: + CONFIG_RESULT: ${{ needs.docs-config.result }} + BUILD_RESULT: ${{ needs.docs.result }} + run: | + if [ "$CONFIG_RESULT" != success ] || [ "$BUILD_RESULT" != success ]; then + echo "::error::Documentation checks did not succeed (configuration: $CONFIG_RESULT, build: $BUILD_RESULT). A maintainer must authorize gated builds with the 'safe to test' label." + exit 1 + fi tests: name: Unit tests diff --git a/README.md b/README.md index fcd9ddf..2d20a9d 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,18 @@ node --test tests/test_version_selector.js ``` The selector tests require Node.js 18 or newer. CI runs these tests and builds -the versioned documentation on pull requests. +the versioned documentation on authorized pull requests, uploading the generated +site as the `versioned-api-documentation` artifact. For contributors outside the +existing trust gate, a maintainer must add the `safe to test` label to authorize +the full build. + +The **Documentation version configuration** check runs independently of that +gate. It validates the candidate `api-docs/versions.json` with the base branch's +validator, without importing candidate Python code. The **Documentation checks** +job fails unless both configuration validation and the full docs build succeed, +including when the build is skipped. Configure branch protection or repository +rules to require **Documentation checks** so skipped builds cannot satisfy the +merge requirement. ## License diff --git a/tests/test_versioned_docs.py b/tests/test_versioned_docs.py index 00564b6..d5b8e64 100644 --- a/tests/test_versioned_docs.py +++ b/tests/test_versioned_docs.py @@ -12,6 +12,9 @@ class VersionedDocsTests(unittest.TestCase): + def test_checked_in_version_list_is_valid(self): + docs.load_versions(docs.ROOT / "api-docs/versions.json") + def test_version_list_preserves_default_and_order(self): with tempfile.TemporaryDirectory() as directory: config = Path(directory) / "versions.json" @@ -34,6 +37,16 @@ def test_invalid_version_lists_are_rejected(self): with self.assertRaises(ValueError): docs.load_versions(config) + def test_malformed_version_json_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "versions.json" + config.write_text( + '{"versions": ["v0.26.17","versions": ["v0.26.16"]}', + encoding="utf-8", + ) + with self.assertRaises(json.JSONDecodeError): + docs.load_versions(config) + @patch.object(docs.subprocess, "run") def test_published_release_is_verified(self, run): run.return_value = Mock(