Files
docker-docs/hack/sbx-release-notes.py
Craig Osterhout 140b37a933 dhi: add release notes (#25245)
<!--Delete sections as needed -->

## Description

- Added CLI and platform release notes for DHI.
- Modified sbx release note script to also fetch dhictl and fix some
formatting issues introduced by the DHI CLI release notes.


https://deploy-preview-25245--docsdocker.netlify.app/dhi/release-notes/platform/

https://deploy-preview-25245--docsdocker.netlify.app/dhi/release-notes/cli/

## Related issues or tickets


## Reviews

<!-- Notes for reviewers here -->
<!-- List applicable reviews (optionally @tag reviewers) -->

- [ ] Editorial review
- [ ] Product review

Signed-off-by: Craig Osterhout <craig.osterhout@docker.com>
2026-06-10 08:36:47 -07:00

212 lines
6.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 a GitHub releases page and splice them into
a docs markdown file between the BEGIN/END markers.
Usage (from repo root):
./hack/sbx-release-notes.py
./hack/sbx-release-notes.py --preset dhi
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
PRESETS: dict[str, dict] = {
"sbx": {
"repo": "docker/sbx-releases",
"file": Path("content/manuals/ai/sandboxes/release-notes.md"),
},
"dhi": {
"repo": "docker-hardened-images/dhictl",
"file": Path("content/manuals/dhi/release-notes/cli.md"),
},
}
DEFAULT_PRESET = "sbx"
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": normalize_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 normalize_body(body: str) -> str:
"""Fix markdownlint issues in release body content:
- Ensure a blank line follows each heading (MD022).
- Add 'console' language tag to fenced code blocks that have none (MD040).
Safe to run on content that already complies — no double blank lines are added."""
lines = body.splitlines()
result: list[str] = []
in_fence = False
for i, line in enumerate(lines):
stripped = line.lstrip(" \t")
if stripped.startswith(("```", "~~~")):
if not in_fence:
# Opening fence: add language tag if missing
fence_marker = "```" if stripped.startswith("```") else "~~~"
lang = stripped[len(fence_marker):].strip()
if not lang:
indent = line[: len(line) - len(stripped)]
line = f"{indent}{fence_marker}console"
in_fence = not in_fence
result.append(line)
continue
result.append(line)
# Outside fences: insert blank line after a heading if the next line is non-empty
if not in_fence and stripped.startswith("#"):
next_line = lines[i + 1] if i + 1 < len(lines) else ""
if next_line.strip():
result.append("")
return "\n".join(result)
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(
"--preset",
choices=list(PRESETS),
default=DEFAULT_PRESET,
help="Named preset that sets --repo and --file defaults (default: %(default)s)",
)
p.add_argument("--repo", default=None, help="GitHub repo (owner/name), overrides preset")
p.add_argument("--file", type=Path, default=None, help="Target markdown file, overrides preset")
p.add_argument("--minor-releases", type=int, default=DEFAULT_MINOR_RELEASES)
args = p.parse_args()
preset = PRESETS[args.preset]
repo = args.repo or preset["repo"]
file = args.file or preset["file"]
releases = pick_minor_releases(parse_stable(fetch(repo)), args.minor_releases)
if not releases:
sys.exit("no stable releases found")
generated = TEMPLATE.render(releases=releases)
splice(file, generated)
if shutil.which("npx"):
subprocess.run(["npx", "--no-install", "prettier", "--write", str(file)], check=False)
print(f"Wrote {len(releases)} releases (latest {args.minor_releases} minor releases) to {file}")
if __name__ == "__main__":
main()