|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Create a GitHub release for a new Devin CLI manifest version. |
| 3 | +
|
| 4 | +Reads the published manifest, and if its version has not yet been released, |
| 5 | +creates a GitHub release for that version whose notes link to the corresponding |
| 6 | +entry in the CLI changelog. No binaries are attached — the binaries are served |
| 7 | +from static.devin.ai and the release simply records the version. |
| 8 | +
|
| 9 | +Usage: |
| 10 | + # Check only (no release); exits 0 if up to date, 10 if a new version exists |
| 11 | + python scripts/release_from_manifest.py --check |
| 12 | +
|
| 13 | + # Create the release if the manifest version is new |
| 14 | + python scripts/release_from_manifest.py |
| 15 | +
|
| 16 | +Environment variables: |
| 17 | + GH_TOKEN / GITHUB_TOKEN: token used by the `gh` CLI to create the release. |
| 18 | +""" |
| 19 | + |
| 20 | +import argparse |
| 21 | +import json |
| 22 | +import re |
| 23 | +import subprocess |
| 24 | +import sys |
| 25 | +import urllib.error |
| 26 | +import urllib.request |
| 27 | + |
| 28 | +MANIFEST_URL = "https://static.devin.ai/cli/current/manifest.json" |
| 29 | +CHANGELOG_BASE = "https://docs.devin.ai/cli/changelog/stable" |
| 30 | +USER_AGENT = "devin-cli-release-bot/1.0" |
| 31 | + |
| 32 | +NEW_VERSION_EXIT_CODE = 10 |
| 33 | + |
| 34 | + |
| 35 | +def fetch_manifest(url: str) -> dict: |
| 36 | + """Download and parse the manifest JSON.""" |
| 37 | + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) |
| 38 | + with urllib.request.urlopen(req, timeout=60) as response: |
| 39 | + return json.loads(response.read().decode("utf-8")) |
| 40 | + |
| 41 | + |
| 42 | +def get_version(manifest: dict) -> str: |
| 43 | + """Extract and validate the version from the manifest.""" |
| 44 | + version = manifest.get("version") |
| 45 | + if not isinstance(version, str) or not re.fullmatch(r"[0-9A-Za-z][0-9A-Za-z.+-]*", version): |
| 46 | + raise ValueError(f"Manifest has an invalid version: {version!r}") |
| 47 | + return version |
| 48 | + |
| 49 | + |
| 50 | +def changelog_url(version: str) -> str: |
| 51 | + """Build the changelog anchor URL for a version (e.g. 2026.5.26-7 -> #2026-5-26-7).""" |
| 52 | + anchor = version.replace(".", "-") |
| 53 | + return f"{CHANGELOG_BASE}#{anchor}" |
| 54 | + |
| 55 | + |
| 56 | +def build_release_notes(version: str) -> str: |
| 57 | + """Build markdown release notes linking to the changelog entry.""" |
| 58 | + return f"See the [changelog]({changelog_url(version)}) for what's included in `{version}`." |
| 59 | + |
| 60 | + |
| 61 | +def release_exists(tag: str) -> bool: |
| 62 | + """Return True if a GitHub release with the given tag already exists.""" |
| 63 | + result = subprocess.run( |
| 64 | + ["gh", "release", "view", tag], |
| 65 | + capture_output=True, |
| 66 | + text=True, |
| 67 | + ) |
| 68 | + return result.returncode == 0 |
| 69 | + |
| 70 | + |
| 71 | +def create_release(tag: str, version: str, notes: str) -> None: |
| 72 | + """Create a GitHub release (no assets) via the gh CLI.""" |
| 73 | + subprocess.run( |
| 74 | + ["gh", "release", "create", tag, "--title", version, "--notes", notes], |
| 75 | + check=True, |
| 76 | + ) |
| 77 | + |
| 78 | + |
| 79 | +def main() -> int: |
| 80 | + parser = argparse.ArgumentParser(description=__doc__) |
| 81 | + parser.add_argument( |
| 82 | + "--check", |
| 83 | + action="store_true", |
| 84 | + help="Only report whether a new version exists; do not create a release.", |
| 85 | + ) |
| 86 | + parser.add_argument( |
| 87 | + "--manifest-url", |
| 88 | + default=MANIFEST_URL, |
| 89 | + help="Override the manifest URL (for testing).", |
| 90 | + ) |
| 91 | + args = parser.parse_args() |
| 92 | + |
| 93 | + try: |
| 94 | + manifest = fetch_manifest(args.manifest_url) |
| 95 | + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: |
| 96 | + print(f"ERROR: could not fetch manifest: {exc}", file=sys.stderr) |
| 97 | + return 1 |
| 98 | + |
| 99 | + try: |
| 100 | + version = get_version(manifest) |
| 101 | + except ValueError as exc: |
| 102 | + print(f"ERROR: {exc}", file=sys.stderr) |
| 103 | + return 1 |
| 104 | + |
| 105 | + tag = version |
| 106 | + print(f"Manifest version: {version}") |
| 107 | + |
| 108 | + if release_exists(tag): |
| 109 | + print(f"Release {tag} already exists; nothing to do.") |
| 110 | + return 0 |
| 111 | + |
| 112 | + print(f"Release {tag} does not exist yet.") |
| 113 | + if args.check: |
| 114 | + return NEW_VERSION_EXIT_CODE |
| 115 | + |
| 116 | + notes = build_release_notes(version) |
| 117 | + print(f"Creating release {tag}...") |
| 118 | + create_release(tag, version, notes) |
| 119 | + print(f"Created release {tag}.") |
| 120 | + return 0 |
| 121 | + |
| 122 | + |
| 123 | +if __name__ == "__main__": |
| 124 | + sys.exit(main()) |
0 commit comments