Skip to content

Commit 3622b91

Browse files
authored
Merge pull request #465 from modelcontextprotocol/feat/weekly-releases
feat: add weekly release automation
2 parents d3136ce + 9db47b2 commit 3622b91

File tree

3 files changed

+340
-0
lines changed

3 files changed

+340
-0
lines changed

.github/workflows/release-check.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Daily Release Check
2+
3+
on:
4+
# Allow manual trigger for testing
5+
workflow_dispatch:
6+
7+
jobs:
8+
prepare:
9+
runs-on: ubuntu-latest
10+
outputs:
11+
matrix: ${{ steps.set-matrix.outputs.matrix }}
12+
last_release: ${{ steps.last-release.outputs.hash }}
13+
steps:
14+
- uses: actions/checkout@v4
15+
with:
16+
fetch-depth: 0
17+
18+
- name: Find package directories
19+
id: set-matrix
20+
run: |
21+
DIRS=$(git ls-tree -r HEAD --name-only | grep -E "package.json|pyproject.toml" | xargs dirname | grep -v "^.$" | jq -R -s -c 'split("\n")[:-1]')
22+
echo "matrix=${DIRS}" >> $GITHUB_OUTPUT
23+
24+
- name: Get last release hash
25+
id: last-release
26+
run: |
27+
HASH=$(git rev-list --tags --max-count=1 || echo "HEAD~1")
28+
echo "hash=${HASH}" >> $GITHUB_OUTPUT
29+
30+
check-release:
31+
needs: prepare
32+
runs-on: ubuntu-latest
33+
strategy:
34+
matrix:
35+
directory: ${{ fromJson(needs.prepare.outputs.matrix) }}
36+
fail-fast: false
37+
38+
steps:
39+
- uses: actions/checkout@v4
40+
with:
41+
fetch-depth: 0
42+
43+
- uses: astral-sh/setup-uv@v5
44+
45+
- name: Setup Node.js
46+
if: endsWith(matrix.directory, 'package.json')
47+
uses: actions/setup-node@v4
48+
with:
49+
node-version: '18'
50+
51+
- name: Setup Python
52+
if: endsWith(matrix.directory, 'pyproject.toml')
53+
run: uv python install
54+
55+
- name: Check release
56+
run: |
57+
uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}"

.github/workflows/release.yml

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: Daily Release
2+
3+
on:
4+
schedule:
5+
# Run every day at 9:00 UTC
6+
- cron: '0 9 * * *'
7+
# Allow manual trigger for testing
8+
workflow_dispatch:
9+
10+
jobs:
11+
prepare:
12+
runs-on: ubuntu-latest
13+
outputs:
14+
matrix: ${{ steps.set-matrix.outputs.matrix }}
15+
last_release: ${{ steps.last-release.outputs.hash }}
16+
steps:
17+
- uses: actions/checkout@v4
18+
with:
19+
fetch-depth: 0
20+
21+
- name: Find package directories
22+
id: set-matrix
23+
run: |
24+
DIRS=$(git ls-tree -r HEAD --name-only | grep -E "package.json|pyproject.toml" | xargs dirname | grep -v "^.$" | jq -R -s -c 'split("\n")[:-1]')
25+
echo "matrix=${DIRS}" >> $GITHUB_OUTPUT
26+
27+
- name: Get last release hash
28+
id: last-release
29+
run: |
30+
HASH=$(git rev-list --tags --max-count=1 || echo "HEAD~1")
31+
echo "hash=${HASH}" >> $GITHUB_OUTPUT
32+
33+
release:
34+
needs: prepare
35+
runs-on: ubuntu-latest
36+
strategy:
37+
matrix:
38+
directory: ${{ fromJson(needs.prepare.outputs.matrix) }}
39+
fail-fast: false
40+
permissions:
41+
contents: write
42+
packages: write
43+
44+
steps:
45+
- uses: actions/checkout@v4
46+
with:
47+
fetch-depth: 0
48+
49+
- uses: astral-sh/setup-uv@v5
50+
51+
- name: Setup Node.js
52+
if: endsWith(matrix.directory, 'package.json')
53+
uses: actions/setup-node@v4
54+
with:
55+
node-version: '18'
56+
registry-url: 'https://registry.npmjs.org'
57+
58+
- name: Setup Python
59+
if: endsWith(matrix.directory, 'pyproject.toml')
60+
run: uv python install
61+
62+
- name: Release package
63+
env:
64+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
65+
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
66+
run: uv run --script scripts/release.py "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" >> "$GITHUB_OUTPUT"
67+
68+
create-release:
69+
needs: [prepare, release]
70+
runs-on: ubuntu-latest
71+
permissions:
72+
contents: write
73+
steps:
74+
- uses: actions/checkout@v4
75+
76+
- name: Create Release
77+
env:
78+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
79+
run: |
80+
# Check if there's output from release step
81+
if [ -s "$GITHUB_OUTPUT" ]; then
82+
DATE=$(date +%Y.%m.%d)
83+
84+
# Create git tag
85+
git tag -s -a -m"automated release v${DATE}" "v${DATE}"
86+
git push origin "v${DATE}"
87+
88+
# Create release notes
89+
echo "# Release ${DATE}" > notes.md
90+
echo "" >> notes.md
91+
echo "## Updated Packages" >> notes.md
92+
93+
# Read updated packages from github output
94+
while IFS= read -r line; do
95+
echo "- ${line}" >> notes.md
96+
done < "$GITHUB_OUTPUT"
97+
98+
# Create GitHub release
99+
gh release create "v${DATE}" \
100+
--title "Release ${DATE}" \
101+
--notes-file notes.md
102+
fi

scripts/release.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
#!/usr/bin/env uv run --script
2+
# /// script
3+
# requires-python = ">=3.11"
4+
# dependencies = [
5+
# "click>=8.1.8",
6+
# "tomlkit>=0.13.2"
7+
# ]
8+
# ///
9+
import sys
10+
import re
11+
import click
12+
from pathlib import Path
13+
import json
14+
import tomlkit
15+
import datetime
16+
import subprocess
17+
from enum import Enum
18+
from typing import Any, NewType
19+
20+
21+
Version = NewType("Version", str)
22+
GitHash = NewType("GitHash", str)
23+
24+
25+
class GitHashParamType(click.ParamType):
26+
name = "git_hash"
27+
28+
def convert(
29+
self, value: Any, param: click.Parameter | None, ctx: click.Context | None
30+
) -> GitHash | None:
31+
if value is None:
32+
return None
33+
34+
if not (8 <= len(value) <= 40):
35+
self.fail(f"Git hash must be between 8 and 40 characters, got {len(value)}")
36+
37+
if not re.match(r"^[0-9a-fA-F]+$", value):
38+
self.fail("Git hash must contain only hex digits (0-9, a-f)")
39+
40+
try:
41+
# Verify hash exists in repo
42+
subprocess.run(
43+
["git", "rev-parse", "--verify", value], check=True, capture_output=True
44+
)
45+
except subprocess.CalledProcessError:
46+
self.fail(f"Git hash {value} not found in repository")
47+
48+
return GitHash(value.lower())
49+
50+
51+
GIT_HASH = GitHashParamType()
52+
53+
54+
class PackageType(Enum):
55+
NPM = 1
56+
PYPI = 2
57+
58+
@classmethod
59+
def from_path(cls, directory: Path) -> "PackageType":
60+
if (directory / "package.json").exists():
61+
return cls.NPM
62+
elif (directory / "pyproject.toml").exists():
63+
return cls.PYPI
64+
else:
65+
raise Exception("No package.json or pyproject.toml found")
66+
67+
68+
def get_changes(path: Path, git_hash: str) -> bool:
69+
"""Check if any files changed between current state and git hash"""
70+
try:
71+
output = subprocess.run(
72+
["git", "diff", "--name-only", git_hash, "--", path],
73+
cwd=path,
74+
check=True,
75+
capture_output=True,
76+
text=True,
77+
)
78+
79+
changed_files = [Path(f) for f in output.stdout.splitlines()]
80+
relevant_files = [f for f in changed_files if f.suffix in ['.py', '.ts']]
81+
return len(relevant_files) >= 1
82+
except subprocess.CalledProcessError:
83+
return False
84+
85+
86+
def get_package_name(path: Path, pkg_type: PackageType) -> str:
87+
"""Get package name from package.json or pyproject.toml"""
88+
match pkg_type:
89+
case PackageType.NPM:
90+
with open(path / "package.json", "rb") as f:
91+
return json.load(f)["name"]
92+
case PackageType.PYPI:
93+
with open(path / "pyproject.toml") as f:
94+
toml_data = tomlkit.parse(f.read())
95+
name = toml_data.get("project", {}).get("name")
96+
if not name:
97+
raise Exception("No name in pyproject.toml project section")
98+
return str(name)
99+
100+
101+
def generate_version() -> Version:
102+
"""Generate version based on current date"""
103+
now = datetime.datetime.now()
104+
return Version(f"{now.year}.{now.month}.{now.day}")
105+
106+
107+
def publish_package(
108+
path: Path, pkg_type: PackageType, version: Version, dry_run: bool = False
109+
):
110+
"""Publish package based on type"""
111+
try:
112+
match pkg_type:
113+
case PackageType.NPM:
114+
# Update version in package.json
115+
with open(path / "package.json", "rb+") as f:
116+
data = json.load(f)
117+
data["version"] = version
118+
f.seek(0)
119+
json.dump(data, f, indent=2)
120+
f.truncate()
121+
122+
if not dry_run:
123+
# Publish to npm
124+
subprocess.run(["npm", "publish"], cwd=path, check=True)
125+
case PackageType.PYPI:
126+
# Update version in pyproject.toml
127+
with open(path / "pyproject.toml") as f:
128+
data = tomlkit.parse(f.read())
129+
data["project"]["version"] = version
130+
131+
with open(path / "pyproject.toml", "w") as f:
132+
f.write(tomlkit.dumps(data))
133+
134+
if not dry_run:
135+
# Build and publish to PyPI
136+
subprocess.run(["uv", "build"], cwd=path, check=True)
137+
subprocess.run(
138+
["uv", "publish", "--username", "__token__"],
139+
cwd=path,
140+
check=True,
141+
)
142+
except Exception as e:
143+
raise Exception(f"Failed to publish: {e}") from e
144+
145+
146+
@click.command()
147+
@click.argument("directory", type=click.Path(exists=True, path_type=Path))
148+
@click.argument("git_hash", type=GIT_HASH)
149+
@click.option(
150+
"--dry-run", is_flag=True, help="Update version numbers but don't publish"
151+
)
152+
def main(directory: Path, git_hash: GitHash, dry_run: bool) -> int:
153+
"""Release package if changes detected"""
154+
# Detect package type
155+
try:
156+
path = directory.resolve(strict=True)
157+
pkg_type = PackageType.from_path(path)
158+
except Exception as e:
159+
return 1
160+
161+
# Check for changes
162+
if not get_changes(path, git_hash):
163+
return 0
164+
165+
try:
166+
# Generate version and publish
167+
version = generate_version()
168+
name = get_package_name(path, pkg_type)
169+
170+
publish_package(path, pkg_type, version, dry_run)
171+
if not dry_run:
172+
click.echo(f"{name}@{version}")
173+
else:
174+
click.echo(f"🔍 Dry run: Would have published {name}@{version} if this was a real release")
175+
return 0
176+
except Exception as e:
177+
return 1
178+
179+
180+
if __name__ == "__main__":
181+
sys.exit(main())

0 commit comments

Comments
 (0)