Skip to content

Commit bd4163e

Browse files
authored
Add hourly workflow to release new Devin CLI manifest versions (#1)
1 parent a7e1931 commit bd4163e

2 files changed

Lines changed: 155 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Release from manifest
2+
3+
on:
4+
schedule:
5+
# Run hourly at minute 0
6+
- cron: "0 * * * *"
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: write
11+
12+
concurrency:
13+
group: release-from-manifest
14+
cancel-in-progress: false
15+
16+
jobs:
17+
release:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@v4
22+
23+
- name: Set up Python
24+
uses: actions/setup-python@v5
25+
with:
26+
python-version: "3.12"
27+
28+
- name: Create release for new manifest version
29+
env:
30+
GH_TOKEN: ${{ github.token }}
31+
run: python scripts/release_from_manifest.py

scripts/release_from_manifest.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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

Comments
 (0)