' \
+ > site/index.html
+ fi
+ - name: Retain and stage complete Pages tree
+ env:
+ RELEASE_TAG: ${{ steps.release.outputs.tag }}
+ run: |
+ set -euo pipefail
+ git -C site config user.name 'github-actions[bot]'
+ git -C site config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git -C site add --all
+ if ! git -C site diff --cached --quiet; then
+ git -C site commit -m "site: publish ${RELEASE_TAG}"
+ git -C site push origin HEAD:coverage-pages
+ fi
+ mkdir -p public
+ rsync --archive --exclude='.git' site/ public/
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
- path: _site
- - name: Deploy GitHub Pages
+ path: public
+ - name: Deploy complete site
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e4c5d7b..7ee3369 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -130,10 +130,11 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
+ bash tools/release_notes.sh "$tag" > release-notes.md
gh release view "$tag" >/dev/null 2>&1 || \
gh release create "$tag" \
--title "coderef $tag" \
- --notes "Automated release. See CHANGELOG when one ships, or DESIGN.md §20 for the planning horizon."
+ --notes-file release-notes.md
gh release upload "$tag" _dist/* --clobber
npm:
diff --git a/README.md b/README.md
index 2a643eb..0599b56 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# coderef
+[Release website](https://mboworks.github.io/coderef/)
+
Regex-driven references in source code — resolved, click-opened, and
verified identically from VSCode and from CI. A `.coderef.jsonc` config
declares the patterns; the same engine runs inside the editor (via WASM,
@@ -87,3 +89,90 @@ the reasoning.
## License
Apache License 2.0. See [`LICENSE`](./LICENSE).
+
+## Release website
+
+Release notes use `.github/release-notes.md.template`, rendered by
+`tools/release_notes.sh TAG`, to link to that tag's versioned website and related
+release resources, including the changelog and versioned JSON schema.
+
+The [website](https://mboworks.github.io/coderef/) forwards to the latest published
+stable release at `site/tag//`, preserving the exact Git tag name.
+Each release keeps its converted HTML, images, and configured files. Retrying
+publication leaves an existing snapshot unchanged; a different commit cannot
+replace it. Older versions remain directly accessible.
+
+[`release-site.json`](release-site.json) defines the layout. Source names are
+relative to the repository root; destinations are relative to that release's
+site directory. For example:
+
+```json
+{
+ "pages": {
+ "README.md": "index.html",
+ "docs/guide.md": "guide/index.html"
+ },
+ "files": {
+ "schema/example.json": "schema/v1.json"
+ },
+ "links": [
+ {
+ "label": "Release",
+ "href": "https://github.com/{owner}/{repo}/releases/tag/{tag}"
+ }
+ ]
+}
+```
+
+Use existing source files in the actual configuration. `pages` converts Markdown;
+optional `files` copies other files unchanged. `README.md` must map to `index.html`.
+The generated `documents.html`, `release.json`, `release-site.json`, and `assets/`
+paths are reserved. Destination paths cannot have hidden components (names starting
+with a dot), because the Pages artifact uploader excludes them. Hidden source
+paths remain valid; for example, `.github/workflows/README.md` maps to
+`workflows/index.html`.
+Navigation links support `{owner}`, `{repo}`, `{tag}`, `{version}`, and `{commit}`.
+`{version}` omits a leading `v` for compatibility with coverage report paths.
+By default, the configuration and content come from the release tag. Every linked
+local Markdown page (including directory README links) must have a `pages` mapping.
+Publication fails for an omitted mapping, a missing generated file, or a broken
+anchor within the snapshot. Links to configured pages follow their destination
+mappings; other local source links use the exact release commit. Embedded images are copied, including remote badges. Markdown
+conversion uses the [GitHub Markdown API](https://docs.github.com/en/rest/markdown/markdown)
+at publication time; browsing the result requires no Markdown renderer or CDN.
+
+After the Release workflow succeeds, `Publish release site` retains the snapshot
+on `coverage-pages` and deploys the complete Pages tree. Coverage and site
+publication share a concurrency group to preserve both trees. GitHub's latest
+stable release selects the root redirect; backfilling an older release does not
+make it latest. The workflow can also be dispatched with a published tag to retry
+publication. Enable GitHub Pages with
+**GitHub Actions** as its source, and set the repository's About website to
+`https://mboworks.github.io/coderef/`.
+
+### Backfill a historical release
+
+No new release or tag change is needed. Manually dispatch `Publish release site`
+with `tag` set to the historical release and `config_path` set to a tracked JSON
+file on `main`. Leave `config_path` empty to use a configuration already in the tag.
+For example, after selecting a compatible configuration and an existing tag:
+
+```sh
+gh workflow run pages.yml --repo mboworks/coderef --ref main \
+ -f tag="$RELEASE_TAG" -f config_path=release-site.json
+```
+
+The override controls only publication layout; all Markdown and copied files come
+from the selected tag. Each new snapshot retains the exact configuration as
+`release-site.json`, with its SHA-256, origin, and source commit in `release.json`.
+A configuration can serve several historical tags when its sources exist in each.
+For another layout, commit another configuration and select its path. Missing
+sources or links fail publication instead of using newer content. Retrying a
+published tag preserves its original HTML and configuration.
+
+Local regression tests: `python3 -m unittest discover -s tools -p release_site_test.py`.
+CI also converts the configured documentation and checks the generated links in
+a disposable runner directory. It never commits, retains, or deploys that preview.
+
+Main-branch pushes continue to update `/coderef/schema/v1.json`; each release
+also retains and links to its own frozen schema copy inside its site directory.
diff --git a/docs/release.md b/docs/release.md
index 45376e4..f5ca3b8 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -211,3 +211,7 @@ For a release candidate, use the SemVer pre-release form: tag as
`v0.2.0-rc1`, bump all five `version` fields to `0.2.0-rc.1` (note
the `.` before the number for npm/SemVer; Cargo accepts both). Test
publish to a private npm scope or skip npm entirely for rcs.
+
+Release notes are rendered from `.github/release-notes.md.template` by
+`tools/release_notes.sh TAG`. They link to the immutable website and JSON schema
+for that exact tag, together with its changelog.
diff --git a/release-site.json b/release-site.json
new file mode 100644
index 0000000..817ba0b
--- /dev/null
+++ b/release-site.json
@@ -0,0 +1,25 @@
+{
+ "pages": {
+ "README.md": "index.html",
+ "CHANGELOG.md": "CHANGELOG.html",
+ "docs/README.md": "docs/README.html",
+ "docs/release.md": "docs/release.html",
+ "docs/test-plan.md": "docs/test-plan.html",
+ "extension/README.md": "extension/README.html",
+ "npm/coderef/README.md": "npm/coderef/README.html",
+ "schema/README.md": "schema/README.html",
+ "DESIGN.md": "DESIGN.html",
+ "AGENTS.md": "AGENTS.html",
+ "CLAUDE.md": "CLAUDE.html",
+ "extension/CHANGELOG.md": "extension/CHANGELOG.html"
+ },
+ "links": [
+ {
+ "label": "JSON Schema v1",
+ "href": "/{repo}/site/tag/{tag}/schema/v1.json"
+ }
+ ],
+ "files": {
+ "schema/coderef.schema.json": "schema/v1.json"
+ }
+}
diff --git a/tools/release_notes.sh b/tools/release_notes.sh
new file mode 100755
index 0000000..b29ecbd
--- /dev/null
+++ b/tools/release_notes.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Render release-note links without building, publishing, or changing repository state.
+set -euo pipefail
+
+TAG="${1:?Usage: release_notes.sh TAG}"
+if [[ ! "${TAG}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
+ echo "Invalid release tag: ${TAG}" >&2
+ exit 1
+fi
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+sed -e "s|@TAG@|${TAG}|g" -e "s|@VERSION@|${TAG#v}|g" \
+ "${ROOT}/.github/release-notes.md.template"
diff --git a/tools/release_notes_test.py b/tools/release_notes_test.py
new file mode 100644
index 0000000..6cdab6e
--- /dev/null
+++ b/tools/release_notes_test.py
@@ -0,0 +1,52 @@
+# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors
+# SPDX-License-Identifier: Apache-2.0
+"""Exercise the release-note renderer with the actual repository template."""
+
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+REPO = "coderef"
+
+
+class ReleaseNotesTest(unittest.TestCase):
+ def render(self, tag):
+ with tempfile.TemporaryDirectory() as cwd:
+ return subprocess.run(
+ ["bash", str(ROOT / "tools/release_notes.sh"), tag],
+ cwd=cwd, text=True, capture_output=True, check=False)
+
+ def test_release_resources(self):
+ for tag in ("1.2.3", "v1.2.3", "v1.2.3-rc.1"):
+ with self.subTest(tag=tag):
+ result = self.render(tag)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ notes = result.stdout
+ base = f"https://mboworks.github.io/{REPO}"
+ self.assertIn(f"{base}/site/tag/{tag}/", notes)
+ self.assertNotIn("@TAG@", notes)
+ self.assertNotIn("@VERSION@", notes)
+ version = tag.removeprefix("v")
+ if REPO in ("mbo", "xff", "carve"):
+ self.assertIn(f"{base}/coverage/tag/{version}/", notes)
+ else:
+ self.assertNotIn("/coverage/", notes)
+ if REPO == "xff":
+ self.assertIn(f"{base}/releases/{version}/)", notes)
+ self.assertIn(f"{base}/releases/{version}/XFF.md", notes)
+ if REPO == "coderef":
+ self.assertIn(f"/blob/{tag}/CHANGELOG.md", notes)
+ self.assertIn(f"{base}/site/tag/{tag}/schema/v1.json", notes)
+
+ def test_reject_invalid_tags_without_partial_notes(self):
+ for tag in ("", "main", "../1.2.3", "v1.2.3|bad", "1.2.3\nmain"):
+ with self.subTest(tag=tag):
+ result = self.render(tag)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertEqual(result.stdout, "")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/release_site.py b/tools/release_site.py
new file mode 100644
index 0000000..f5634cd
--- /dev/null
+++ b/tools/release_site.py
@@ -0,0 +1,362 @@
+#!/usr/bin/env python3
+# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors
+# SPDX-License-Identifier: Apache-2.0
+"""Convert a release checkout to a retained, self-contained documentation site."""
+
+import argparse
+import hashlib
+import html
+from html.parser import HTMLParser
+import json
+from pathlib import Path
+import posixpath
+import re
+import subprocess
+import tempfile
+from urllib.parse import quote, unquote, urlsplit, urlunsplit
+from urllib.request import urlopen
+
+
+STYLE = """
+:root { color-scheme: light dark; font: 17px/1.6 system-ui, sans-serif; }
+body { max-width: 76rem; margin: auto; padding: 2rem; }
+a { color: light-dark(#075da8, #8cc8ff); }
+nav { display: flex; flex-wrap: wrap; gap: 1rem; border-bottom: 1px solid #888; }
+pre { padding: 1rem; overflow: auto; background: light-dark(#f3f5f7, #20252b); }
+code { font-size: .9em; } img { max-width: 100%; }
+table { display: block; overflow: auto; border-collapse: collapse; }
+th, td { border: 1px solid #888; padding: .4rem .7rem; }
+blockquote { border-left: 4px solid #888; margin-left: 0; padding-left: 1rem; }
+"""
+
+
+def git(source, *args):
+ return subprocess.check_output(["git", "-C", str(source), *args], text=True).strip()
+
+
+def render(markdown, repository):
+ return subprocess.check_output(
+ ["gh", "api", "markdown", "--input", "-"],
+ input=json.dumps({"text": markdown, "mode": "gfm", "context": repository}),
+ text=True,
+ )
+
+
+def version(tag):
+ if not re.fullmatch(r"v?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", tag):
+ raise ValueError(f"Invalid release tag: {tag!r}")
+ return tag.removeprefix("v")
+
+
+def configuration(source, override=None):
+ data = (override if override is not None else source / "release-site.json").read_bytes()
+ config = json.loads(data)
+ pages = config["pages"]
+ if not pages or pages.get("README.md") != "index.html":
+ raise ValueError("README.md must map to index.html")
+ destinations = set()
+ for src, dst in pages.items():
+ for path in (src, dst):
+ if (not isinstance(path, str) or path.startswith("/")
+ or ".." in Path(path).parts or str(Path(path)) != path
+ or any(char in path for char in "\\?#")):
+ raise ValueError(f"Unsafe site path: {path!r}")
+ if not src.endswith(".md") or not dst.endswith(".html"):
+ raise ValueError("Page mappings must convert .md sources to .html destinations")
+ if dst in destinations or dst == "documents.html" or dst.startswith("assets/"):
+ raise ValueError(f"Duplicate or reserved destination: {dst}")
+ if any(part.startswith(".") for part in Path(dst).parts):
+ raise ValueError(f"Pages excludes hidden destinations: {dst}")
+ destinations.add(dst)
+ for src, dst in config.get("files", {}).items():
+ if src in pages:
+ raise ValueError(f"Source is mapped as both a page and a file: {src}")
+ if src.lower().endswith(".md"):
+ raise ValueError(f"Markdown must be converted through pages: {src}")
+ for path in (src, dst):
+ if (not isinstance(path, str) or path.startswith("/")
+ or ".." in Path(path).parts or str(Path(path)) != path
+ or any(char in path for char in "\\?#")):
+ raise ValueError(f"Unsafe asset path: {path!r}")
+ if dst in destinations or dst in ("documents.html", "release.json", "release-site.json") or dst.startswith("assets/"):
+ raise ValueError(f"Duplicate or reserved destination: {dst}")
+ if any(part.startswith(".") for part in Path(dst).parts):
+ raise ValueError(f"Pages excludes hidden destinations: {dst}")
+ destinations.add(dst)
+ return config, data
+
+
+def headings(body):
+ """The Markdown API omits GitHub's heading anchors; restore their slugs."""
+ used = set()
+
+ def heading(match):
+ level, attrs, text = match.groups()
+ plain = html.unescape(re.sub(r"<[^>]*>", "", text)).lower()
+ slug = re.sub(r"[^\w\- ]", "", plain).replace(" ", "-")
+ anchor = slug
+ count = 0
+ while anchor in used:
+ count += 1
+ anchor = f"{slug}-{count}"
+ used.add(anchor)
+ return f'{text}'
+
+ return re.sub(r"]*)>(.*?)", heading, body, flags=re.DOTALL)
+
+
+class Links(HTMLParser):
+ def __init__(self, source, output, document, documents, repository, sha, tag):
+ super().__init__(convert_charrefs=False)
+ self.source = source
+ self.output = output
+ self.document = document
+ self.documents = documents
+ self.repository = repository
+ self.sha = sha
+ self.tag = tag
+ self.parts = []
+
+ def local(self, value):
+ parsed = urlsplit(value)
+ prefix = f"https://github.com/{self.repository}/"
+ if value.startswith(prefix):
+ rest = value[len(prefix):]
+ for kind in ("blob/", "tree/"):
+ for ref in ("main", "master", self.tag, self.sha):
+ start = f"{kind}{ref}/"
+ if rest.startswith(start):
+ return posixpath.normpath(unquote(urlsplit(rest[len(start):]).path))
+ if parsed.scheme or parsed.netloc or not parsed.path:
+ return None
+ path = unquote(parsed.path)
+ if path.startswith("/"):
+ return posixpath.normpath(path.lstrip("/"))
+ return posixpath.normpath(posixpath.join(posixpath.dirname(self.document), path))
+
+ def rewrite(self, value, image=False):
+ parsed = urlsplit(value)
+ owner, repo = self.repository.split("/")
+ coverage = f"/{repo}/coverage/"
+ if not image and (value.startswith(coverage) or value.startswith(f"https://{owner}.github.io{coverage}")):
+ return f"/{repo}/coverage/tag/{version(self.tag)}/"
+ local = self.local(value)
+ if local is not None:
+ if local == ".." or local.startswith("../"):
+ raise ValueError(f"Link escapes repository: {value}")
+ readme = f"{local.rstrip('/')}/README.md"
+ if local not in self.documents and (readme in self.documents or (self.source / readme).is_file()):
+ local = readme
+ if local == ".":
+ local = "README.md"
+ if local in self.documents and not image:
+ target = posixpath.relpath(self.documents[local], posixpath.dirname(self.documents[self.document]) or ".")
+ return urlunsplit(("", "", quote(target), parsed.query, parsed.fragment))
+ if not image and local.lower().endswith(".md"):
+ raise ValueError(f"{self.document}: linked Markdown has no page mapping: {local}")
+ path = self.source / local
+ if image:
+ if not path.is_file() or path.resolve() != path.absolute():
+ raise ValueError(f"Missing or symlinked image: {local}")
+ data = path.read_bytes()
+ suffix = path.suffix
+ else:
+ kind = "tree" if path.is_dir() else "blob"
+ return f"https://github.com/{self.repository}/{kind}/{self.sha}/{quote(local)}" + (
+ f"#{parsed.fragment}" if parsed.fragment else ""
+ )
+ elif image:
+ if parsed.scheme not in ("https", "http"):
+ raise ValueError(f"Unsupported image URL: {value}")
+ with urlopen(value, timeout=60) as response:
+ data = response.read()
+ media = response.headers.get_content_type()
+ suffix = {"image/svg+xml": ".svg", "image/png": ".png", "image/jpeg": ".jpg",
+ "image/gif": ".gif", "image/webp": ".webp"}.get(media)
+ if suffix is None:
+ raise ValueError(f"Unsupported image content type: {media}")
+ else:
+ return value
+ asset = f"assets/{hashlib.sha256(data).hexdigest()}{suffix}"
+ (self.output / "assets").mkdir(exist_ok=True)
+ (self.output / asset).write_bytes(data)
+ return posixpath.relpath(asset, posixpath.dirname(self.documents[self.document]) or ".")
+
+ def handle_starttag(self, tag, attrs):
+ rewritten = []
+ for key, value in attrs:
+ if key in ("data-canonical-src", "srcset"):
+ continue
+ if value is not None and key in ("href", "src"):
+ value = self.rewrite(value, image=tag == "img" and key == "src")
+ rewritten.append(key if value is None else f'{key}="{html.escape(value, quote=True)}"')
+ self.parts.append(f"<{tag}{' ' if rewritten else ''}{' '.join(rewritten)}>")
+
+ def handle_startendtag(self, tag, attrs):
+ self.handle_starttag(tag, attrs)
+
+ def handle_endtag(self, tag):
+ self.parts.append(f"{tag}>")
+
+ def handle_data(self, data):
+ self.parts.append(data)
+
+ def handle_entityref(self, name):
+ self.parts.append(f"&{name};")
+
+ def handle_charref(self, name):
+ self.parts.append(f"{name};")
+
+
+class PageReferences(HTMLParser):
+ """Collect browser-visible anchors and links from final HTML."""
+
+ def __init__(self, text):
+ super().__init__()
+ self.anchors = set()
+ self.links = []
+ self.feed(text)
+
+ def handle_starttag(self, tag, attrs):
+ for key, value in attrs:
+ if value is None:
+ continue
+ if key == "id" or (tag == "a" and key == "name"):
+ self.anchors.add(value)
+ if key in ("href", "src"):
+ self.links.append(value)
+
+ handle_startendtag = handle_starttag
+
+
+def validate_site(output, repository, tag):
+ """Reject broken links inside the snapshot before retaining or deploying it."""
+ owner, repo = repository.split("/")
+ base = f"/{repo}/site/tag/{tag}/"
+ host = f"{owner}.github.io"
+ pages = {path.relative_to(output).as_posix(): PageReferences(path.read_text())
+ for path in output.rglob("*.html")}
+ for document, page in pages.items():
+ for link in page.links:
+ parsed = urlsplit(link)
+ if parsed.scheme or parsed.netloc:
+ if parsed.scheme not in ("http", "https") or parsed.netloc != host:
+ continue
+ if not parsed.path.startswith(base):
+ continue
+ path = unquote(parsed.path)
+ if path.startswith("/"):
+ if not path.startswith(base):
+ continue # Coverage and other explicitly separate Pages trees.
+ target = posixpath.normpath(path[len(base):])
+ elif path:
+ target = posixpath.normpath(posixpath.join(posixpath.dirname(document), path))
+ else:
+ target = document
+ if target == ".." or target.startswith("../"):
+ raise ValueError(f"{document}: link escapes release snapshot: {link}")
+ destination = output / target
+ if destination.is_dir():
+ target = posixpath.join(target, "index.html")
+ destination = output / target
+ target = posixpath.normpath(target)
+ if not destination.is_file():
+ raise ValueError(f"{document}: missing generated link target: {link}")
+ fragment = unquote(parsed.fragment)
+ if fragment and target in pages and fragment not in pages[target].anchors:
+ raise ValueError(f"{document}: missing generated anchor: {link}")
+
+
+def build(source, retained, repository, tag, renderer=render, config_path=None):
+ source = source.resolve()
+ release_version = version(tag)
+ destination = retained / "site" / "tag" / tag
+ sha = git(source, "rev-parse", "HEAD")
+ if destination.exists():
+ metadata = json.loads((destination / "release.json").read_text())
+ if metadata["commit"] != sha or metadata["tag"] != tag:
+ raise ValueError("A retained release cannot be replaced by a different commit or tag")
+ return
+ config, config_data = configuration(source, config_path)
+ documents = config["pages"]
+ files = config.get("files", {})
+ tracked = set(git(source, "ls-files", "-z").split("\0"))
+ for document in [*documents, *files]:
+ path = source / document
+ if document not in tracked or not path.is_file() or path.resolve() != path.absolute():
+ raise ValueError(f"Missing, untracked, or symlinked document: {document}")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory(dir=destination.parent) as temporary:
+ output = Path(temporary)
+ for src, dst in files.items():
+ target = output / dst
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes((source / src).read_bytes())
+ owner, repo = repository.split("/")
+ base = f"/{repo}/site/tag/{tag}/"
+ links = [("Home", base), ("Documentation", base + "documents.html"),
+ ("Release & downloads", f"https://github.com/{repository}/releases/tag/{tag}"),
+ ("Source", f"https://github.com/{repository}/tree/{sha}")]
+ for link in config.get("links", []):
+ links.append((link["label"], link["href"].format(
+ repo=repo, owner=owner, tag=tag, version=release_version, commit=sha)))
+ navigation = "".join(f'{html.escape(label)}' for label, url in links)
+
+ def page(title, body):
+ return (f''
+ f''
+ f'{html.escape(title)} - {repo} {tag}'
+ f'