diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 00000000..22f53b0b --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,60 @@ +name: Create new release + +# Manually-triggered workflow that prepares a new Invidious release by running +# scripts/create-release.py. All the logic lives in that script (which can also +# be run by hand); this workflow only provides checkout, Python and secrets. +# +# The script computes the version, collects every merged PR since the last +# release, generates the changelog prose via an OpenAI-compatible API (any +# endpoint via OPENAI_BASE_URL; OpenRouter by default), bumps shard.yml + +# CHANGELOG.md, and opens a release PR. Tagging and publishing stay manual, as +# the release documentation requires maintainer review. + +on: + workflow_dispatch: + inputs: + major: + description: "Major version. Empty = keep current. Bump only for breaking changes." + required: false + default: "" + patch: + description: "Patch number (increment for a 2nd release on the same UTC day)." + required: false + default: "0" + model: + description: "Model used to write the changelog." + required: false + default: "deepseek/deepseek-v4-pro" + base_url: + description: "OpenAI-compatible API base URL (default: OpenRouter)." + required: false + default: "https://openrouter.ai/api/v1" + +permissions: + contents: write + pull-requests: write + +jobs: + prepare-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: master + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Prepare release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_BASE_URL: ${{ inputs.base_url }} + run: | + python3 scripts/create-release.py \ + ${{ inputs.major && format('--major {0}', inputs.major) || '' }} \ + --patch "${{ inputs.patch }}" \ + --model "${{ inputs.model }}" diff --git a/scripts/create-release.py b/scripts/create-release.py new file mode 100755 index 00000000..f0ba678b --- /dev/null +++ b/scripts/create-release.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""Prepare a new Invidious release. + +This automates the release procedure documented at +https://github.com/iv-org/documentation/blob/master/docs/create-new-invidious-release.md + +What it does (all of it, so the GitHub workflow only has to run this script): + 1. Computes the next version `..` from shard.yml. + 2. Collects EVERY pull request merged since the previous release, via `gh`. + 3. Asks an LLM (OpenRouter API) to write only the changelog *prose* + (wrap-up + categorised sections), using recent CHANGELOG.md as a style + guide. The exhaustive PR list is generated deterministically so no PR is + ever missed. + 4. Bumps shard.yml and prepends the new section to CHANGELOG.md. + 5. Creates the `release-vX.Y.Z` branch, commits, pushes, and opens a PR to + master with the post-merge checklist (tagging + publishing stay manual, + as the docs require maintainer review and forbid squash-merges/retagging). + +The LLM is called through any OpenAI-compatible Chat Completions endpoint, +configured with the standard `OPENAI_API_KEY` / `OPENAI_BASE_URL` environment +variables (as used by the official OpenAI SDK). It defaults to OpenRouter, but +point it at OpenAI, a local server, etc. by setting `OPENAI_BASE_URL`. + +Manual usage: + export OPENAI_API_KEY=sk-or-... # OpenRouter key by default + ./scripts/create-release.py # full run: edits, commit, push, PR + ./scripts/create-release.py --dry-run # only edit files locally + ./scripts/create-release.py --major 3 # bump major (breaking changes) + ./scripts/create-release.py --patch 1 # 2nd release on the same day + ./scripts/create-release.py --model x/y # override the model + # Use vanilla OpenAI instead of OpenRouter: + OPENAI_BASE_URL=https://api.openai.com/v1 \ + ./scripts/create-release.py --model gpt-4o + +Requirements: python3, git, and the GitHub CLI (`gh`) authenticated (or +GH_TOKEN set). Only OPENAI_API_KEY is mandatory (skippable with --dry-run +combined with --pr-json for offline testing). +""" + +import argparse +import datetime +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + +DEFAULT_BASE_URL = "https://openrouter.ai/api/v1" +DEFAULT_MODEL = "deepseek/deepseek-v4-pro" +REPO_URL = "https://github.com/iv-org/invidious" +CHANGELOG_FILE = "CHANGELOG.md" +SHARD_FILE = "shard.yml" + + +def die(msg): + prefix = "::error::" if os.environ.get("GITHUB_ACTIONS") else "error: " + print(f"{prefix}{msg}", file=sys.stderr) + sys.exit(1) + + +def run(cmd, capture=False, check=True): + """Run a subprocess command (list of args).""" + print(f"$ {' '.join(cmd)}", file=sys.stderr) + result = subprocess.run( + cmd, check=False, text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + ) + if check and result.returncode != 0: + detail = (result.stderr or "").strip() if capture else "" + die(f"command failed ({result.returncode}): {' '.join(cmd)}\n{detail}") + return (result.stdout or "").strip() if capture else "" + + +# --------------------------------------------------------------------------- # +# Version + git helpers +# --------------------------------------------------------------------------- # +def read_current_version(): + with open(SHARD_FILE, encoding="utf-8") as fh: + for line in fh: + m = re.match(r"^version:\s*(\S+)", line) + if m: + return m.group(1) + die(f"no 'version:' line found in {SHARD_FILE}") + + +def compute_version(args): + current = read_current_version() # e.g. 2.20260626.0-dev + print(f"Current shard.yml version: {current}", file=sys.stderr) + major = str(args.major) if args.major is not None else current.split(".", 1)[0] + date = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d") + version = f"{major}.{date}.{args.patch}" + tag = f"v{version}" + existing = run(["git", "tag", "--list", tag], capture=True) + if existing: + die(f"tag {tag} already exists; pass --patch {args.patch + 1}") + return version, tag + + +def previous_tag(): + tags = run(["git", "tag", "--list", "v*", "--sort=-creatordate"], + capture=True) + return tags.splitlines()[0].strip() if tags else "" + + +def tag_date(tag): + return run(["git", "log", "-1", "--format=%cI", tag], capture=True) + + +# --------------------------------------------------------------------------- # +# Pull request collection +# --------------------------------------------------------------------------- # +def collect_prs(repo, prev_tag, pr_json_path): + if pr_json_path: + with open(pr_json_path, encoding="utf-8") as fh: + prs = json.load(fh) + else: + cmd = ["gh", "pr", "list", "--repo", repo, "--base", "master", + "--state", "merged", "--limit", "1000", + "--json", "number,title,url,author,mergedAt"] + if prev_tag: + since = tag_date(prev_tag) + print(f"Listing PRs merged after {since} ({prev_tag})", + file=sys.stderr) + cmd += ["--jq", f'[.[] | select(.mergedAt > "{since}")]'] + else: + print("No previous tag; listing all merged PRs.", file=sys.stderr) + out = run(cmd, capture=True) + prs = json.loads(out) if out else [] + prs.sort(key=lambda p: p.get("mergedAt") or "", reverse=True) + print(f"Collected {len(prs)} merged PR(s).", file=sys.stderr) + return prs + + +def format_author(author): + """Match the CHANGELOG convention (`gh` returns bots as `app/dependabot`, + but the changelog uses `dependabot[bot]`).""" + login = (author or {}).get("login") or "ghost" + if login.startswith("app/"): + return f"{login[len('app/'):]}[bot]" + return login + + +def format_pr_list(prs): + lines = [] + for pr in prs: + author = format_author(pr.get("author")) + title = (pr.get("title") or "").strip() + lines.append(f"* {title} ({REPO_URL}/pull/{pr.get('number')}, " + f"by @{author})") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- # +# Changelog generation +# --------------------------------------------------------------------------- # +def extract_style_reference(changelog, max_chars=6000): + matches = list(re.finditer(r"^## v\S+.*$", changelog, flags=re.MULTILINE)) + for i, m in enumerate(matches): + if "(future)" in m.group(0): + continue + start = m.start() + end = matches[i + 1].start() if i + 1 < len(matches) else len(changelog) + section = changelog[start:end].strip() + section = re.split(r"^### Full list of pull requests", section, + flags=re.MULTILINE)[0].strip() + return section[:max_chars] + return "" + + +def build_prompt(prs, style_ref, version): + pr_bullets = "\n".join( + f"- #{p.get('number')}: {(p.get('title') or '').strip()}" for p in prs) + return f"""You are writing the changelog prose for the Invidious release v{version}. + +Below is an example of a PAST release entry from CHANGELOG.md. Match its \ +Markdown structure, tone and heading style EXACTLY (a "### Wrap-up" narrative \ +of 2-3 short paragraphs, then "### New features & important changes" with \ +"#### For Users", "#### For instance owners" and "#### For developers" \ +sub-sections, then a "### Bugs fixed" section when relevant). + +--- STYLE EXAMPLE START --- +{style_ref} +--- STYLE EXAMPLE END --- + +Here is the complete list of pull requests merged since the last release, \ +which you must summarise (reference PRs inline as "(#1234)"). Sort content by \ +user impact, with new features and breaking changes first: + +{pr_bullets} + +Rules: +- Output ONLY the Markdown body starting at "### Wrap-up". Do NOT include the \ +"## v{version}" header. +- Do NOT include the exhaustive "### Full list of pull requests merged since \ +the last release" section; it is appended separately. +- Do NOT invent PRs or changes that are not in the list above. +- Keep it concise and factual.""" + + +def call_llm(base_url, api_key, model, prompt): + url = base_url.rstrip("/") + "/chat/completions" + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "You are a meticulous release " + "manager for the Invidious project who writes clear, accurate " + "changelogs in Markdown."}, + {"role": "user", "content": prompt}, + ], + "temperature": 0.3, + } + req = urllib.request.Request( + url, data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + # OpenRouter-specific attribution headers; ignored by other APIs. + "HTTP-Referer": REPO_URL, + "X-Title": "Invidious release changelog", + }, method="POST") + try: + with urllib.request.urlopen(req, timeout=180) as resp: + body = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + die(f"LLM API HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')}") + except urllib.error.URLError as exc: + die(f"LLM API request failed: {exc}") + try: + content = body["choices"][0]["message"]["content"].strip() + except (KeyError, IndexError): + die(f"unexpected LLM API response: {json.dumps(body)[:1000]}") + if not content: + die("LLM API returned empty content") + return content + + +def generate_prose(prs, version, base_url, api_key, model): + if not prs: + return ("### Wrap-up\n\nNo pull requests were merged since the " + "previous release.") + with open(CHANGELOG_FILE, encoding="utf-8") as fh: + changelog = fh.read() + prompt = build_prompt(prs, extract_style_reference(changelog), version) + return call_llm(base_url, api_key, model, prompt) + + +# --------------------------------------------------------------------------- # +# File edits +# --------------------------------------------------------------------------- # +def update_changelog(version, entry_body): + with open(CHANGELOG_FILE, encoding="utf-8") as fh: + content = fh.read() + section = f"## v{version}\n\n{entry_body}\n" + future = re.search(r"^## v\S*\s*\(future\)\s*$", content, flags=re.MULTILINE) + if future: + at = future.end() + rest = content[at:].lstrip("\n") + content = content[:at] + "\n\n" + section + "\n" + rest + else: + title = re.search(r"^# CHANGELOG\s*$", content, flags=re.MULTILINE) + at = title.end() if title else 0 + content = (content[:at] + "\n\n" + section + "\n" + + content[at:].lstrip("\n")) + with open(CHANGELOG_FILE, "w", encoding="utf-8") as fh: + fh.write(content) + + +def update_shard(version): + with open(SHARD_FILE, encoding="utf-8") as fh: + content = fh.read() + content, n = re.subn(r"^version:\s*.*$", f"version: {version}", content, + count=1, flags=re.MULTILINE) + if n != 1: + die(f"could not update 'version:' in {SHARD_FILE}") + with open(SHARD_FILE, "w", encoding="utf-8") as fh: + fh.write(content) + + +# --------------------------------------------------------------------------- # +# Git / GitHub +# --------------------------------------------------------------------------- # +def detect_repo(explicit): + if explicit: + return explicit + env = os.environ.get("GITHUB_REPOSITORY") + if env: + return env + url = run(["git", "remote", "get-url", "origin"], capture=True, check=False) + m = re.search(r"github\.com[:/](.+?)(?:\.git)?$", url) + return m.group(1) if m else "iv-org/invidious" + + +def create_pull_request(repo, tag, branch, model, summary): + if run(["git", "config", "user.name"], capture=True, check=False) == "": + run(["git", "config", "user.name", "github-actions[bot]"]) + run(["git", "config", "user.email", + "41898282+github-actions[bot]@users.noreply.github.com"]) + run(["git", "checkout", "-b", branch]) + run(["git", "add", CHANGELOG_FILE, SHARD_FILE]) + run(["git", "commit", "-m", f"Release {tag}"]) + run(["git", "push", "-u", "origin", branch]) + + body = f"""Automated release preparation for **{tag}**. + +Bumps `{SHARD_FILE}` and updates `{CHANGELOG_FILE}` (prose generated by the \ +`{model}` model; the PR list is generated deterministically so every merged \ +PR is included). + +## Before merging +- [ ] Review the generated changelog for accuracy +- [ ] Confirm the version number and major bump (if any) + +## After merging (maintainer, per the release docs) +- Do **not** squash-merge; keep the release commit distinct. +- Tag the merge commit: `git tag -as {tag}` using the changelog summary as \ +the annotation, then `git push origin {tag}`. +- Run the "Prepare for next release" step (append `-dev` in {SHARD_FILE}, add \ +a new `(future)` changelog header). +- Create the GitHub release from the tag with the changelog summary + PR list. + +Refer to the documentation for more details: +https://github.com/iv-org/documentation/blob/master/docs/create-new-invidious-release.md + +### Proposed tag annotation (changelog summary) +```markdown +{summary} +``` +""" + run(["gh", "pr", "create", "--repo", repo, "--base", "master", + "--head", branch, "--title", f"Release {tag}", "--body", body]) + + +# --------------------------------------------------------------------------- # +def main(): + parser = argparse.ArgumentParser(description="Prepare an Invidious release.") + parser.add_argument("--major", type=int, default=None, + help="major version (default: keep current)") + parser.add_argument("--patch", type=int, default=0, + help="patch number (default: 0)") + parser.add_argument("--model", default=os.environ.get( + "LLM_MODEL", DEFAULT_MODEL), + help=f"model name (default: {DEFAULT_MODEL})") + parser.add_argument("--base-url", default=os.environ.get( + "OPENAI_BASE_URL", DEFAULT_BASE_URL), + help=f"OpenAI-compatible API base URL (default: {DEFAULT_BASE_URL})") + parser.add_argument("--repo", default=None, + help="owner/repo (default: autodetect)") + parser.add_argument("--pr-json", default=None, + help="read PR list from a JSON file instead of gh") + parser.add_argument("--dry-run", action="store_true", + help="edit files only; no branch/commit/push/PR") + args = parser.parse_args() + + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + die("OPENAI_API_KEY is not set") + + repo = detect_repo(args.repo) + version, tag = compute_version(args) + branch = f"release-{tag}" + prev = previous_tag() + print(f"Preparing {tag} (previous: {prev or ''}) on {repo}", + file=sys.stderr) + + prs = collect_prs(repo, prev, args.pr_json) + prose = generate_prose(prs, version, args.base_url, api_key, args.model) + + entry_body = prose + pr_list = format_pr_list(prs) + if pr_list: + entry_body += ("\n\n### Full list of pull requests merged since the " + "last release (newest first)\n\n" + pr_list) + + update_changelog(version, entry_body) + update_shard(version) + print(f"Updated {CHANGELOG_FILE} and {SHARD_FILE} for {tag}.", + file=sys.stderr) + + if args.dry_run: + print(f"\n--- Dry run complete. Review the diff, then:\n" + f" git checkout -b {branch}\n" + f" git add {CHANGELOG_FILE} {SHARD_FILE}\n" + f" git commit -m 'Release {tag}'\n" + f" git push -u origin {branch}\n" + f" gh pr create --base master --head {branch} " + f"--title 'Release {tag}'", file=sys.stderr) + return + + create_pull_request(repo, tag, branch, args.model, prose) + print(f"\nOpened release PR for {tag}.", file=sys.stderr) + + +if __name__ == "__main__": + main()