|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Prepend a new release section to CHANGES.md from a GitHub release. Used in CI/CD.""" |
| 3 | + |
| 4 | +import datetime as dt |
| 5 | +import os |
| 6 | +import sys |
| 7 | +import traceback |
| 8 | +from pathlib import Path |
| 9 | + |
| 10 | +import httpx |
| 11 | + |
| 12 | +_CHANGES_PATH = Path(__file__).parent.joinpath('CHANGES.md').absolute() |
| 13 | + |
| 14 | +_GITHUB_API_BASE_URL = 'https://api.github.com' |
| 15 | +_DEFAULT_REPO = 'MarshalX/atproto' |
| 16 | + |
| 17 | +_TOP_HEADING = '# Change Log' |
| 18 | + |
| 19 | +_DROP_HEADING = "## What's Changed" |
| 20 | +_FOOTER_MARKERS = ('**Full Changelog**', '## New Contributors') |
| 21 | + |
| 22 | + |
| 23 | +def _emit_github_error(message: str, title: str = 'update_changelog.py failed') -> None: |
| 24 | + """Surface a failure as a GitHub Actions error annotation (no-op outside CI).""" |
| 25 | + if 'CI' not in os.environ: |
| 26 | + return |
| 27 | + |
| 28 | + # Escape per the GitHub workflow-command spec so multi-line tracebacks render. |
| 29 | + escaped = message.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') |
| 30 | + print(f'::error title={title}::{escaped}') # noqa: T201 |
| 31 | + |
| 32 | + |
| 33 | +def _set_output(name: str, value: str) -> None: |
| 34 | + """Expose a value to later workflow steps via $GITHUB_OUTPUT.""" |
| 35 | + github_output = os.environ.get('GITHUB_OUTPUT') |
| 36 | + if not github_output: |
| 37 | + return |
| 38 | + |
| 39 | + with open(github_output, 'a', encoding='UTF-8') as f: |
| 40 | + f.write(f'{name}={value}\n') |
| 41 | + |
| 42 | + |
| 43 | +def _print(*args) -> None: |
| 44 | + if 'CI' in os.environ: |
| 45 | + print(*args, file=sys.stderr) # noqa: T201 |
| 46 | + return |
| 47 | + |
| 48 | + print(*args) # noqa: T201 |
| 49 | + |
| 50 | + |
| 51 | +def _require_env(name: str) -> str: |
| 52 | + value = os.environ.get(name) |
| 53 | + if not value: |
| 54 | + raise RuntimeError(f'Missing required environment variable: {name}') |
| 55 | + |
| 56 | + return value |
| 57 | + |
| 58 | + |
| 59 | +def _normalize_version(tag: str) -> str: |
| 60 | + return tag[1:] if tag.startswith('v') else tag |
| 61 | + |
| 62 | + |
| 63 | +def _format_date(published_at: str) -> str: |
| 64 | + parsed = dt.datetime.fromisoformat(published_at.replace('Z', '+00:00')) |
| 65 | + return parsed.strftime('%d.%m.%Y') |
| 66 | + |
| 67 | + |
| 68 | +def _fetch_release(tag: str) -> dict: |
| 69 | + """Fetch a release from the GitHub API. Empty ``tag`` resolves to the latest release. |
| 70 | +
|
| 71 | + Used by the manual (workflow_dispatch) path, where there is no release event payload. |
| 72 | + """ |
| 73 | + repo = os.environ.get('GITHUB_REPOSITORY') or _DEFAULT_REPO |
| 74 | + if tag: |
| 75 | + url = f'{_GITHUB_API_BASE_URL}/repos/{repo}/releases/tags/{tag}' |
| 76 | + else: |
| 77 | + url = f'{_GITHUB_API_BASE_URL}/repos/{repo}/releases/latest' |
| 78 | + |
| 79 | + headers = {'Accept': 'application/vnd.github+json'} |
| 80 | + token = os.environ.get('GITHUB_TOKEN') |
| 81 | + if token: |
| 82 | + headers['Authorization'] = f'Bearer {token}' |
| 83 | + |
| 84 | + _print(f'- Fetching release from {url} ...') |
| 85 | + response = httpx.get(url, headers=headers, follow_redirects=True) |
| 86 | + response.raise_for_status() |
| 87 | + return response.json() |
| 88 | + |
| 89 | + |
| 90 | +def _clean_body(body: str) -> str: |
| 91 | + """Strip GitHub's auto-notes scaffolding (``## What's Changed`` header and footer).""" |
| 92 | + lines = body.replace('\r\n', '\n').split('\n') |
| 93 | + |
| 94 | + # Cut the footer and everything after it |
| 95 | + for idx, line in enumerate(lines): |
| 96 | + if any(line.strip().startswith(marker) for marker in _FOOTER_MARKERS): |
| 97 | + lines = lines[:idx] |
| 98 | + break |
| 99 | + |
| 100 | + # Drop the leading heading |
| 101 | + lines = [line for line in lines if line.strip() != _DROP_HEADING] |
| 102 | + |
| 103 | + return '\n'.join(lines).strip() |
| 104 | + |
| 105 | + |
| 106 | +def _build_section(version: str, date: str, body: str) -> str: |
| 107 | + return f'## Version {version}\n\n**{date}**\n\n{body}\n' |
| 108 | + |
| 109 | + |
| 110 | +def _insert_section(changes: str, section: str) -> str: |
| 111 | + """Insert the new section right after the top-level heading, before the latest version.""" |
| 112 | + heading = f'{_TOP_HEADING}\n' |
| 113 | + if not changes.startswith(heading): |
| 114 | + raise RuntimeError(f'CHANGES.md does not start with the expected {_TOP_HEADING!r} heading') |
| 115 | + |
| 116 | + rest = changes[len(heading) :].lstrip('\n') |
| 117 | + return f'{heading}\n{section}\n{rest}' |
| 118 | + |
| 119 | + |
| 120 | +def _replace_section(changes: str, version: str, section: str) -> str: |
| 121 | + """Replace an existing ``## Version X.Y.Z`` block in place (used by --force / past releases).""" |
| 122 | + marker = f'## Version {version}\n' |
| 123 | + start = changes.find(marker) |
| 124 | + if start == -1: |
| 125 | + raise RuntimeError(f'Version {version} not found in CHANGES.md') |
| 126 | + |
| 127 | + # The next version heading marks the end of this block; -1 means it is the last block. |
| 128 | + next_heading = changes.find('\n## Version ', start + len(marker)) |
| 129 | + end = len(changes) if next_heading == -1 else next_heading + 1 |
| 130 | + |
| 131 | + suffix = changes[end:] |
| 132 | + # Separate from the following section with a blank line; nothing extra at EOF. |
| 133 | + joiner = '\n' if suffix else '' |
| 134 | + return f'{changes[:start]}{section}{joiner}{suffix}' |
| 135 | + |
| 136 | + |
| 137 | +def main() -> None: |
| 138 | + """Prepend a release section to CHANGES.md from the release payload. Used in CI/CD.""" |
| 139 | + # The release event provides the payload directly via env; manual dispatch only knows the |
| 140 | + # tag (or nothing -> latest), so we fetch the release from the API. RELEASE_DATE is the |
| 141 | + # discriminator: it is always present on a release event, never on a manual dispatch. |
| 142 | + if os.environ.get('RELEASE_DATE'): |
| 143 | + tag = _require_env('RELEASE_TAG') |
| 144 | + published_at = os.environ['RELEASE_DATE'] |
| 145 | + body = os.environ.get('RELEASE_BODY', '') |
| 146 | + else: |
| 147 | + release = _fetch_release(os.environ.get('RELEASE_TAG', '')) |
| 148 | + tag = release['tag_name'] |
| 149 | + published_at = release['published_at'] |
| 150 | + body = release.get('body') or '' |
| 151 | + |
| 152 | + version = _normalize_version(tag) |
| 153 | + date = _format_date(published_at) |
| 154 | + body = _clean_body(body) |
| 155 | + |
| 156 | + force = os.environ.get('FORCE', '').strip().lower() in ('true', '1', 'yes') |
| 157 | + |
| 158 | + changes = _CHANGES_PATH.read_text(encoding='UTF-8') |
| 159 | + section = _build_section(version, date, body) |
| 160 | + |
| 161 | + if f'## Version {version}\n' in changes: |
| 162 | + if not force: |
| 163 | + _print(f'- CHANGES.md already contains version {version}; nothing to do (set force to overwrite).') |
| 164 | + _set_output('changed', 'false') |
| 165 | + return |
| 166 | + _CHANGES_PATH.write_text(_replace_section(changes, version, section), encoding='UTF-8') |
| 167 | + _print(f'- Replaced CHANGES.md section for version {version} (force).') |
| 168 | + else: |
| 169 | + _CHANGES_PATH.write_text(_insert_section(changes, section), encoding='UTF-8') |
| 170 | + _print(f'- Added CHANGES.md section for version {version}.') |
| 171 | + |
| 172 | + commit_message = f'Update changelog for v{version}' |
| 173 | + _set_output('changed', 'true') |
| 174 | + _set_output('version', version) |
| 175 | + _set_output('commit_message', commit_message) |
| 176 | + print(commit_message) # noqa: T201 |
| 177 | + |
| 178 | + |
| 179 | +if __name__ == '__main__': |
| 180 | + try: |
| 181 | + main() |
| 182 | + except Exception: |
| 183 | + _emit_github_error(traceback.format_exc()) |
| 184 | + raise |
0 commit comments