Files
docker-docs/hack/sbx-release-notes.py
T
David KarlssonandClaude Opus 4.7 93b011aeee sandboxes: add release notes page sourced from GitHub releases
Add content/manuals/ai/sandboxes/release-notes.md as a single page listing
the latest stable releases of Docker Sandboxes, with a link to the GitHub
release history for older versions.

The block between BEGIN/END GENERATED RELEASES markers is populated by
hack/sbx-release-notes.py — a single-file uv-run script (PEP 723 inline
deps, Jinja2 only) that hits the GitHub Releases API for
docker/sbx-releases, filters to strict-semver stable tags with non-empty
bodies, and emits H2-per-release sections for the latest N minor releases
(default 3) and their patches.

Re-runs are idempotent and only touch content between the markers. The
script also invokes `prettier --write` so the output stays lint-clean.

Extend the existing release-notes Vale skip rule with
content/manuals/**/release-notes.md so the new file under ai/sandboxes/
picks up the same lint exclusions as engine/desktop/build release notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Karlsson <35727626+dvdksn@users.noreply.github.com>
2026-05-21 11:45:16 +02:00

156 lines
4.4 KiB
Python
Executable File

#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["jinja2"]
# ///
"""
Fetch recent stable releases from docker/sbx-releases and splice them into
content/manuals/ai/sandboxes/release-notes.md between the BEGIN/END markers.
Usage (from repo root):
./hack/sbx-release-notes.py
GITHUB_TOKEN=$(gh auth token) ./hack/sbx-release-notes.py
./hack/sbx-release-notes.py --minor-releases 3
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.request
from collections import defaultdict
from pathlib import Path
from jinja2 import Template
DEFAULT_REPO = "docker/sbx-releases"
DEFAULT_FILE = Path("content/manuals/ai/sandboxes/release-notes.md")
DEFAULT_MINOR_RELEASES = 3
BEGIN = "<!-- BEGIN GENERATED RELEASES -->"
END = "<!-- END GENERATED RELEASES -->"
SEMVER = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
TEMPLATE = Template(
"""\
{% for r in releases -%}
## {{ r.version }}
{{ '{{<' }} release-date date="{{ r.date }}" {{ '>}}' }}
[GitHub release]({{ r.url }})
{{ r.body }}
{% endfor -%}
"""
)
def fetch(repo: str) -> list[dict]:
url = f"https://api.github.com/repos/{repo}/releases?per_page=100"
req = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
if token := os.environ.get("GITHUB_TOKEN"):
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req) as resp:
return json.load(resp)
def parse_stable(raw: list[dict]) -> list[dict]:
out = []
for r in raw:
if r.get("prerelease") or r.get("draft"):
continue
m = SEMVER.match(r["tag_name"])
if not m:
continue
body = (r.get("body") or "").strip()
if not body:
continue
major, minor, patch = (int(x) for x in m.groups())
out.append(
{
"major": major,
"minor": minor,
"patch": patch,
"version": f"{major}.{minor}.{patch}",
"date": r["published_at"][:10],
"url": r["html_url"],
"body": shift_headings(body),
}
)
out.sort(key=lambda r: (r["major"], r["minor"], r["patch"]), reverse=True)
return out
def pick_minor_releases(releases: list[dict], n: int) -> list[dict]:
by_minor: dict[tuple[int, int], list[dict]] = defaultdict(list)
for r in releases:
by_minor[(r["major"], r["minor"])].append(r)
latest_keys = sorted(by_minor.keys(), reverse=True)[:n]
keep = set(latest_keys)
return [r for r in releases if (r["major"], r["minor"]) in keep]
def shift_headings(body: str) -> str:
"""Demote ATX headings by one level so body H2s become H3 under the
version's H2. Skips fenced code blocks."""
lines = body.splitlines()
in_fence = False
for i, line in enumerate(lines):
stripped = line.lstrip(" \t")
if stripped.startswith(("```", "~~~")):
in_fence = not in_fence
continue
if in_fence:
continue
if stripped.startswith("#"):
indent = line[: len(line) - len(stripped)]
lines[i] = f"{indent}#{stripped}"
return "\n".join(lines)
def splice(path: Path, generated: str) -> None:
src = path.read_text()
try:
before, rest = src.split(BEGIN, 1)
_, after = rest.split(END, 1)
except ValueError:
sys.exit(f"markers {BEGIN!r} / {END!r} not found in {path}")
path.write_text(f"{before}{BEGIN}\n\n{generated}{END}{after}")
def main() -> None:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--repo", default=DEFAULT_REPO)
p.add_argument("--file", type=Path, default=DEFAULT_FILE)
p.add_argument("--minor-releases", type=int, default=DEFAULT_MINOR_RELEASES)
args = p.parse_args()
releases = pick_minor_releases(parse_stable(fetch(args.repo)), args.minor_releases)
if not releases:
sys.exit("no stable releases found")
generated = TEMPLATE.render(releases=releases)
splice(args.file, generated)
if shutil.which("npx"):
subprocess.run(["npx", "--no-install", "prettier", "--write", str(args.file)], check=False)
print(f"Wrote {len(releases)} releases (latest {args.minor_releases} minor releases) to {args.file}")
if __name__ == "__main__":
main()