From 428582ed58f23a3deb7da6c7a31c21a7db647237 Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:58:08 +0100 Subject: [PATCH] docs: derive landing release links from GitHub metadata --- .github/workflows/native.yml | 8 ++ Documentation/DocumentationTooling.md | 26 +++++ Scripts/build_documentation_site.py | 49 +++++++-- Scripts/documentation_release.py | 106 +++++++++++++++++++ Scripts/test_documentation_site.py | 143 ++++++++++++++++++++++++-- 5 files changed, 315 insertions(+), 17 deletions(-) create mode 100644 Scripts/documentation_release.py diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index c202eb9..2f1e707 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -26,7 +26,14 @@ jobs: with: persist-credentials: false fetch-depth: 0 + - name: Observe latest stable release for documentation + if: matrix.lane == 'docs' + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 -B Scripts/documentation_release.py --output .build/handoff/pages/latest-release.json - run: python3 -B Scripts/ci.py --lane '${{ matrix.lane }}' + env: + SWIFT_JSON_RELEASE_METADATA: ${{ github.workspace }}/.build/handoff/pages/latest-release.json - name: Public composition and reference imports if: matrix.lane == 'native' run: python3 -B Scripts/ci.py --lane composition @@ -47,6 +54,7 @@ jobs: .build/handoff/docc/*-diagnostics.json .build/handoff/docc/*.log .build/handoff/pages/receipt.json + .build/handoff/pages/latest-release.json .build/handoff/pages/site.MANIFEST.sha256 .build/handoff/pages/*.log - name: Package qualified documentation site diff --git a/Documentation/DocumentationTooling.md b/Documentation/DocumentationTooling.md index b34fee2..a6a987b 100644 --- a/Documentation/DocumentationTooling.md +++ b/Documentation/DocumentationTooling.md @@ -81,6 +81,32 @@ The manifest and receipt preserve file-level and archive-level digests. ## Publish and preview the website +Each Native CI documentation build reads GitHub's +[latest published release endpoint](https://docs.github.com/en/rest/releases/releases#get-the-latest-release) +once, using its existing read-only repository token. Drafts and prereleases are +rejected. The exact response is retained as +`.build/handoff/pages/latest-release.json`; its SHA-256, release ID, tag and +publication time are recorded in the Pages receipt and public `build-info.json`. +The landing page derives its stable version, tagged source link and any available +compiled DocC download from that snapshot. A failed request or invalid configured +snapshot fails the CI build instead of reusing a stale release label. This lookup +adds no network behavior to the libraries or the rendered website. + +The references continue to track the checked-out source, independently of the +latest stable release. The source version comes from the existing CLI release +version declaration; the landing heading has no manually maintained version. +Release metadata refreshes whenever the documentation lane runs. For a local +build with the same observed release input, fetch or reuse a retained snapshot: + +```sh +python3 -B Scripts/documentation_release.py --output .build/handoff/pages/latest-release.json +SWIFT_JSON_RELEASE_METADATA=.build/handoff/pages/latest-release.json python3 -B Scripts/ci.py --lane docs +``` + +An unconfigured local preview remains offline and uses a version-neutral latest +release link. An explicitly configured missing or malformed snapshot is an error. +No release tag or frozen documentation archive is changed by this metadata step. + The docs lane uses native `docc process-archive transform-for-static-hosting` to prepare each qualified archive under `/Swift-JSON/`. A shared landing page links all twelve product references and guides. The supporting DSL reference diff --git a/Scripts/build_documentation_site.py b/Scripts/build_documentation_site.py index d17ad71..4036640 100644 --- a/Scripts/build_documentation_site.py +++ b/Scripts/build_documentation_site.py @@ -14,14 +14,12 @@ import urllib.parse from documentation_catalog import PRODUCTS, CATALOGS +from documentation_release import REPOSITORY, load_metadata from preflight import collect ROOT = pathlib.Path(__file__).resolve().parents[1] -REPOSITORY = 'https://github.com/ThorFuchs/Swift-JSON' BASE_PATH = '/Swift-JSON' SITE_URL = 'https://thorfuchs.github.io' + BASE_PATH + '/' -SOURCE_VERSION = '1.7.0' -STABLE_VERSION = '1.6.1' GUIDES = {'JSON': ('Exact values and native coding', 'exactvaluesandcoding'), 'JSONSchema': ('Offline schema validation', 'offlinevalidation'), 'JSONSchemaExamples': ('Verified exact candidates', 'verifiedcandidates'), @@ -43,7 +41,7 @@ 'JSONHTML': 'Exact standalone HTML with offline disclosure/search and native-byte copy/download.', 'JSONBridges': 'Opt-in trusted integer construction and policy-explicit Double and Foundation Decimal projection.', 'JSONNavigation': 'Exact typed DFS with physical and virtual scopes, explicit coverage and one cumulative budget.', - 'JSONInspection': 'Array evidence, complete source-attached snapshots, tree views and strict sparse tables with finite reports.', + 'JSONInspection': 'Array evidence, source-attached snapshots, tree and sparse-table views, exact sets and matrices, and directed graphs with finite reports.', 'JSONTransform': 'Exact query plans and guarded pull executions with cumulative finite limits.', 'JSONValueDSL': 'Trusted authored exact objects and arrays with finite finalization.', 'JSONSchemaDSL': 'Trusted authored schema declarations using native compilation and evaluation.'} @@ -72,7 +70,25 @@ def source_commit(): return subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() -def landing(commit): +def source_version(): + """Read the source line's existing version authority, never the latest release.""" + source = (ROOT / 'Sources/JSONCLIEngine/CLIReleaseVersion.swift').read_text() + versions = re.findall(r'^\s*package static let value = "([^"]+)"\s*$', source, re.MULTILINE) + if len(versions) != 1: + raise ValueError('Missing or ambiguous CLI source version') + return versions[0] + + +def observed_release(): + path = os.environ.get('SWIFT_JSON_RELEASE_METADATA') + if path: + return load_metadata(path) + if os.environ.get('GITHUB_ACTIONS') == 'true': + raise ValueError('CI documentation requires an observed latest-release snapshot') + return None + + +def landing(commit, release=None): cards = [] for product in PRODUCTS: route = BASE_PATH + '/' + product + '/documentation/' + product.lower() + '/' @@ -84,6 +100,15 @@ def landing(commit): {title}''') source = (f'{commit[:12]}' if commit else 'Local preview') + if release: + stable = (f'Latest stable: ' + f'Swift-JSON {html.escape(release["version"])}. For a frozen reference, use its ') + if release['documentation_url']: + stable += f'compiled DocC catalogs or ' + stable += f'tagged source documentation.' + else: + stable = (f'Latest stable release and versioned documentation. ' + 'This local preview has no observed release metadata.') return f''' Swift-JSON Documentation @@ -111,11 +136,11 @@ def landing(commit):
-
Unreleased {SOURCE_VERSION} documentation
+
Swift-JSON documentation

Exact JSON. Clear contracts.

Keep decimal values exact, Unicode scalar keys distinct and resource use explicit. Explore {len(PRODUCTS)} Swift libraries that carry those semantics from input bytes to validated, transformed and encoded output.

Swift 6.4+ · Swift language mode 6 · macOS/SDK 27+ · No package dependencies

-

Development main includes JSONInspection, JSONTransform and the optional authored DSLs; this source is prepared as 1.7.0. Stable {STABLE_VERSION} has eight library catalogs; use its matching compiled DocC assets or tagged source documentation.

+

{stable}

{''.join(cards)}

Start with the contract

Read the package overview and executable examples, then choose a reference above. The behavior contracts explain exact numbers, scalar identity, offline schema evaluation, cumulative budgets and stream completion.

@@ -231,6 +256,8 @@ def main(): try: if receipt['environment']['status'] != 'passed': raise ValueError('Supported native toolchain unavailable') + release = observed_release() + receipt['latest_release'] = release catalogs = ROOT / '.build/handoff/docc' # Verify the exact compiler outputs already packaged by the DocC lane. manifest = catalogs / 'compiled-catalogs.MANIFEST.sha256' @@ -281,7 +308,7 @@ def main(): (site / product / 'index.html').write_text(f'{product} · Swift-JSON

Open the {product} reference.

') commit = source_commit() receipt['source_commit'] = commit - (site / 'index.html').write_text(landing(commit)) + (site / 'index.html').write_text(landing(commit, release)) (site / '.nojekyll').touch() (site / '404.html').write_text(f'Page not found · Swift-JSON

Page not found

Open Swift-JSON documentation or browse the source.

') (site / 'build-info.json').write_text(json.dumps({ @@ -290,8 +317,10 @@ def main(): 'swift': receipt['environment']['swift'], 'sdk': receipt['environment']['sdk'], 'macos': receipt['environment']['macos'], 'swift_language_mode': '6', 'products': list(PRODUCTS), 'catalogs': list(CATALOGS), 'channel': 'development', - 'source_version': SOURCE_VERSION, 'latest_stable_observed': STABLE_VERSION, - 'stable_release': REPOSITORY + '/releases/tag/' + STABLE_VERSION}, indent=2) + '\n') + 'source_version': source_version(), + 'latest_stable_observed': release['tag'] if release else None, + 'stable_release': release['release_url'] if release else None, + 'latest_release': release}, indent=2) + '\n') routes = sorted(p.relative_to(site).parent.as_posix() + '/' for p in site.rglob('index.html')) urls = [SITE_URL if route == './' else SITE_URL + urllib.parse.quote(route, safe='/') for route in routes] (site / 'sitemap.xml').write_text('\n\n' + ''.join('' + html.escape(url) + '\n' for url in urls) + '\n') diff --git a/Scripts/documentation_release.py b/Scripts/documentation_release.py new file mode 100644 index 0000000..f5b1c7d --- /dev/null +++ b/Scripts/documentation_release.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Observe and replay GitHub's latest published stable release for documentation.""" +import argparse +import hashlib +import json +import os +import pathlib +import re +import sys +import urllib.parse +import urllib.request + +REPOSITORY = 'https://github.com/ThorFuchs/Swift-JSON' +API_URL = 'https://api.github.com/repos/ThorFuchs/Swift-JSON/releases/latest' +MAX_METADATA_BYTES = 2 * 1024 * 1024 + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + """Keep the optional read token on the single approved GitHub API endpoint.""" + def redirect_request(self, request, response, code, message, headers, url): + return None + + +def release_metadata(data): + """Validate external metadata and advertise only an actual uploaded DocC asset.""" + if not isinstance(data, dict): + raise ValueError('Expected a GitHub release object') + tag = data.get('tag_name') + if (not isinstance(tag, str) or not re.fullmatch( + r'v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?', tag)): + raise ValueError('Latest release must have a stable semantic version tag') + if (data.get('draft') is not False or data.get('prerelease') is not False + or not isinstance(data.get('published_at'), str) or not data['published_at'] + or type(data.get('id')) is not int or data['id'] <= 0): + raise ValueError('Latest release must be published, non-draft and non-prerelease') + encoded_tag = urllib.parse.quote(tag, safe='') + release_url = REPOSITORY + '/releases/tag/' + encoded_tag + if data.get('html_url') != release_url: + raise ValueError('Release URL must belong to this repository and tag') + version = tag.removeprefix('v') + asset_name = 'Swift-JSON-' + version + '-DocC.tar.gz' + assets = data.get('assets') + if not isinstance(assets, list) or any(not isinstance(a, dict) for a in assets): + raise ValueError('Malformed release asset inventory') + matches = [a for a in assets if a.get('name') == asset_name] + if len(matches) > 1: + raise ValueError('Ambiguous compiled DocC asset') + documentation_url = None + if matches: + asset = matches[0] + expected_url = REPOSITORY + '/releases/download/' + encoded_tag + '/' + urllib.parse.quote(asset_name, safe='') + if (asset.get('state') != 'uploaded' or type(asset.get('size')) is not int + or asset['size'] <= 0 or asset.get('browser_download_url') != expected_url): + raise ValueError('Compiled DocC asset is unavailable or has a foreign URL') + documentation_url = expected_url + return {'release_id': data['id'], 'tag': tag, 'version': version, + 'published_at': data['published_at'], 'release_url': release_url, + 'source_url': REPOSITORY + '/tree/' + encoded_tag + '/Sources', + 'documentation_url': documentation_url} + + +def decode_metadata(raw): + if len(raw) > MAX_METADATA_BYTES: + raise ValueError('Release metadata exceeds the 2 MiB build-input bound') + result = release_metadata(json.loads(raw)) + return dict(result, api_url=API_URL, metadata_sha256=hashlib.sha256(raw).hexdigest()) + + +def load_metadata(path): + with pathlib.Path(path).open('rb') as stream: + return decode_metadata(stream.read(MAX_METADATA_BYTES + 1)) + + +def fetch_metadata(output): + headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2026-03-10', + 'User-Agent': 'Swift-JSON-documentation-build'} + token = os.environ.get('GITHUB_TOKEN') + if token: + headers['Authorization'] = 'Bearer ' + token + request = urllib.request.Request(API_URL, headers=headers) + with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response: + if response.status != 200 or response.url != API_URL: + raise ValueError('Unexpected latest-release response or redirect') + raw = response.read(MAX_METADATA_BYTES + 1) + result = decode_metadata(raw) + output = pathlib.Path(output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(raw) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', required=True, help='Ignored JSON snapshot, retained with CI receipts') + args = parser.parse_args() + try: + print(json.dumps(fetch_metadata(args.output), indent=2)) + except (OSError, ValueError) as error: + print('DOCUMENTATION_RELEASE failed: ' + str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/Scripts/test_documentation_site.py b/Scripts/test_documentation_site.py index 284f874..486bc1b 100644 --- a/Scripts/test_documentation_site.py +++ b/Scripts/test_documentation_site.py @@ -2,13 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 """Regress the guide navigator's runtime fetch contract, beyond static HTML links.""" import json +import hashlib +import os import pathlib import sys import tempfile import unittest +from unittest import mock from build_documentation_site import (PRODUCTS, install_guide_navigation_index, - verify_navigation_indexes, verify_site, landing, SOURCE_VERSION, STABLE_VERSION) + verify_navigation_indexes, verify_site, landing, + observed_release, source_version) +from documentation_release import (API_URL, MAX_METADATA_BYTES, REPOSITORY, + decode_metadata, fetch_metadata, load_metadata, release_metadata) class GuideNavigationTests(unittest.TestCase): @@ -43,11 +49,12 @@ def mutate_native(self, mutate): def test_all_catalogs_keep_native_bytes_and_existing_files(self): before = {p.relative_to(self.site): p.read_bytes() for p in self.site.rglob('*') if p.is_file()} page = landing('2b24f47e7405821240cf8dfc66b840c9a7f8efa5') - self.assertIn('Unreleased ' + SOURCE_VERSION + ' documentation', page) + self.assertIn('Swift-JSON documentation', page) + self.assertNotIn('Unreleased', page) self.assertIn('Explore ' + str(len(PRODUCTS)) + ' Swift libraries', page) - self.assertIn('/releases/tag/' + STABLE_VERSION, page) - self.assertIn('/tree/' + STABLE_VERSION + '/Sources', page) - self.assertIn('Development main includes JSONInspection', page) + self.assertIn('/releases/latest', page) + self.assertIn('This local preview has no observed release metadata.', page) + self.assertIn('These native DocC references track main', page) records = verify_navigation_indexes(self.site) self.assertEqual({r['product'] for r in records}, set(PRODUCTS)) for product in PRODUCTS: @@ -118,9 +125,131 @@ def test_symlink_alias_rejected(self): install_guide_navigation_index(self.site / 'JSON', 'JSON') +class ReleaseMetadataTests(unittest.TestCase): + def fixture(self, tag='2.4.6'): + name = 'Swift-JSON-' + tag.removeprefix('v') + '-DocC.tar.gz' + return {'id': 42, 'tag_name': tag, 'html_url': REPOSITORY + '/releases/tag/' + tag, + 'draft': False, 'prerelease': False, 'published_at': '2026-10-29T09:00:00Z', + 'assets': [{'name': name, 'state': 'uploaded', 'size': 1024, + 'browser_download_url': REPOSITORY + '/releases/download/' + tag + '/' + name}]} + + def test_observed_version_changes_links_without_source_edits(self): + for tag in ('2.4.6', 'v3.2.1'): + with self.subTest(tag=tag): + release = release_metadata(self.fixture(tag)) + page = landing(None, release) + self.assertIn('Swift-JSON ' + tag.removeprefix('v'), page) + self.assertIn(release['release_url'], page) + self.assertIn(release['source_url'], page) + self.assertIn(release['documentation_url'], page) + self.assertNotIn('no observed release metadata', page) + + def test_draft_prerelease_and_unpublished_metadata_rejected(self): + for field, value in (('draft', True), ('prerelease', True), ('published_at', None), ('draft', None), ('id', True)): + with self.subTest(field=field, value=value): + data = self.fixture() + data[field] = value + with self.assertRaisesRegex(ValueError, 'published'): + release_metadata(data) + + def test_untrusted_tags_and_foreign_release_urls_rejected(self): + for tag in ('2.4.6-rc.1', 'latest', '../escape', '