mirror of
https://github.com/docker/docs.git
synced 2026-11-04 10:01:00 +00:00
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>
156 lines
4.4 KiB
Python
Executable File
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()
|