Skip to content

Commit 2086a55

Browse files
committed
add changeset scripts and github action
this is inspired by [changesets](https://github.com/changesets/changesets). it's intended to recreate the ux of them with some simple scripts
1 parent 65adcd1 commit 2086a55

File tree

13 files changed

+1183
-2
lines changed

13 files changed

+1183
-2
lines changed

.changeset/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Changesets
2+
3+
This directory contains changeset files that track changes to the codebase. The changeset system is inspired by the JavaScript changesets tool but adapted for Python projects.
4+
5+
## How it works
6+
7+
1. **Creating a changeset**: When you make changes that should be included in the changelog, run:
8+
```bash
9+
python .changeset/scripts/changeset.py
10+
# or use the wrapper script:
11+
./changeset
12+
```
13+
14+
This will prompt you to:
15+
- Select the type of change (major, minor, or patch)
16+
- Provide a description of the change
17+
18+
A markdown file will be created in this directory with a random name like `warm-chefs-sell.md`.
19+
20+
2. **Version bumping**: The GitHub Action will automatically:
21+
- Detect changesets in PRs to main
22+
- Create or update a "Version Packages" PR
23+
- Bump the version based on the changesets
24+
- Update the CHANGELOG.md
25+
26+
3. **Publishing**: When the "Version Packages" PR is merged:
27+
- The package is automatically published to PyPI
28+
- A GitHub release is created
29+
- The changesets are archived
30+
31+
## Changeset format
32+
33+
Each changeset file looks like:
34+
```markdown
35+
---
36+
"stagehand": patch
37+
---
38+
39+
Fixed a bug in the browser automation logic
40+
```
41+
42+
## Configuration
43+
44+
The changeset behavior is configured in `.changeset/config.json`:
45+
- `baseBranch`: The branch to compare against (usually "main")
46+
- `changeTypes`: Definitions for major, minor, and patch changes
47+
- `package`: Package-specific configuration
48+
49+
## Best practices
50+
51+
1. Create a changeset for every user-facing change
52+
2. Use clear, concise descriptions
53+
3. Choose the appropriate change type:
54+
- `patch`: Bug fixes and small improvements
55+
- `minor`: New features that are backwards compatible
56+
- `major`: Breaking changes
57+
58+
## Workflow
59+
60+
1. Make your code changes
61+
2. Run `./changeset` to create a changeset
62+
3. Commit both your code changes and the changeset file
63+
4. Open a PR
64+
5. The changeset will be processed when the PR is merged to main

.changeset/config.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"baseBranch": "main",
3+
"changelogFormat": "markdown",
4+
"access": "public",
5+
"commit": false,
6+
"changeTypes": {
7+
"major": {
8+
"description": "Breaking changes",
9+
"emoji": "💥"
10+
},
11+
"minor": {
12+
"description": "New features",
13+
"emoji": ""
14+
},
15+
"patch": {
16+
"description": "Bug fixes",
17+
"emoji": "🐛"
18+
}
19+
},
20+
"package": {
21+
"name": "stagehand",
22+
"versionPath": "stagehand/__init__.py",
23+
"versionPattern": "__version__ = \"(.*)\"",
24+
"pyprojectPath": "pyproject.toml"
25+
}
26+
}

.changeset/hard-rivers-dance.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"stagehand": patch
3+
---
4+
5+
Test manual changeset creation

.changeset/scripts/changelog.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Changelog generation script - Generates changelog from processed changesets.
4+
"""
5+
6+
import json
7+
import os
8+
import re
9+
import subprocess
10+
import sys
11+
from datetime import datetime
12+
from pathlib import Path
13+
from typing import Dict, List, Optional
14+
15+
import click
16+
17+
18+
CHANGESET_DIR = Path(".changeset")
19+
CONFIG_FILE = CHANGESET_DIR / "config.json"
20+
CHANGELOG_FILE = Path("CHANGELOG.md")
21+
22+
23+
def load_config() -> Dict:
24+
"""Load changeset configuration."""
25+
if not CONFIG_FILE.exists():
26+
click.echo(click.style("❌ No changeset config found.", fg="red"))
27+
sys.exit(1)
28+
29+
with open(CONFIG_FILE) as f:
30+
return json.load(f)
31+
32+
33+
def load_changeset_data() -> Optional[Dict]:
34+
"""Load processed changeset data."""
35+
data_file = CHANGESET_DIR / ".changeset-data.json"
36+
37+
if not data_file.exists():
38+
return None
39+
40+
with open(data_file) as f:
41+
data = json.load(f)
42+
43+
# Set current date if not set
44+
if data.get("date") is None:
45+
data["date"] = datetime.now().strftime("%Y-%m-%d")
46+
47+
return data
48+
49+
50+
def get_pr_info() -> Optional[Dict[str, str]]:
51+
"""Get PR information if available."""
52+
try:
53+
# Try to get PR info from GitHub context (in Actions)
54+
pr_number = os.environ.get("GITHUB_PR_NUMBER")
55+
if pr_number:
56+
return {
57+
"number": pr_number,
58+
"url": f"https://github.com/{os.environ.get('GITHUB_REPOSITORY')}/pull/{pr_number}"
59+
}
60+
61+
# Try to get from git branch name (if it contains PR number)
62+
result = subprocess.run(
63+
["git", "branch", "--show-current"],
64+
capture_output=True,
65+
text=True
66+
)
67+
68+
if result.returncode == 0:
69+
branch = result.stdout.strip()
70+
# Look for patterns like "pr-123" or "pull/123"
71+
match = re.search(r'(?:pr|pull)[/-](\d+)', branch, re.IGNORECASE)
72+
if match:
73+
pr_number = match.group(1)
74+
# Try to get repo info
75+
repo_result = subprocess.run(
76+
["git", "remote", "get-url", "origin"],
77+
capture_output=True,
78+
text=True
79+
)
80+
if repo_result.returncode == 0:
81+
repo_url = repo_result.stdout.strip()
82+
# Extract owner/repo from URL
83+
match = re.search(r'github\.com[:/]([^/]+/[^/]+?)(?:\.git)?$', repo_url)
84+
if match:
85+
repo = match.group(1)
86+
return {
87+
"number": pr_number,
88+
"url": f"https://github.com/{repo}/pull/{pr_number}"
89+
}
90+
except Exception:
91+
pass
92+
93+
return None
94+
95+
96+
def format_changelog_entry(entry: Dict, config: Dict) -> str:
97+
"""Format a single changelog entry."""
98+
change_type = entry["type"]
99+
description = entry["description"]
100+
101+
# Get emoji if configured
102+
emoji = config["changeTypes"].get(change_type, {}).get("emoji", "")
103+
104+
# Format entry
105+
if emoji:
106+
line = f"- {emoji} **{change_type}**: {description}"
107+
else:
108+
line = f"- **{change_type}**: {description}"
109+
110+
return line
111+
112+
113+
def generate_version_section(data: Dict, config: Dict) -> str:
114+
"""Generate changelog section for a version."""
115+
version = data["version"]
116+
date = data["date"]
117+
entries = data["entries"]
118+
119+
# Start with version header
120+
section = f"## [{version}] - {date}\n\n"
121+
122+
# Get PR info if available
123+
pr_info = get_pr_info()
124+
if pr_info:
125+
section += f"[View Pull Request]({pr_info['url']})\n\n"
126+
127+
# Group entries by type
128+
grouped = {}
129+
for entry in entries:
130+
change_type = entry["type"]
131+
if change_type not in grouped:
132+
grouped[change_type] = []
133+
grouped[change_type].append(entry)
134+
135+
# Add entries by type (in order: major, minor, patch)
136+
type_order = ["major", "minor", "patch"]
137+
138+
for change_type in type_order:
139+
if change_type in grouped:
140+
type_info = config["changeTypes"].get(change_type, {})
141+
type_name = type_info.get("description", change_type.capitalize())
142+
143+
section += f"### {type_name}\n\n"
144+
145+
for entry in grouped[change_type]:
146+
section += format_changelog_entry(entry, config) + "\n"
147+
148+
section += "\n"
149+
150+
return section.strip() + "\n"
151+
152+
153+
def update_changelog(new_section: str, version: str):
154+
"""Update the changelog file with new section."""
155+
if CHANGELOG_FILE.exists():
156+
with open(CHANGELOG_FILE) as f:
157+
current_content = f.read()
158+
else:
159+
# Create new changelog with header
160+
current_content = """# Changelog
161+
162+
All notable changes to this project will be documented in this file.
163+
164+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
165+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
166+
167+
"""
168+
169+
# Check if version already exists
170+
if f"## [{version}]" in current_content:
171+
click.echo(click.style(f"⚠️ Version {version} already exists in changelog", fg="yellow"))
172+
return False
173+
174+
# Find where to insert (after the header, before first version)
175+
lines = current_content.split("\n")
176+
insert_index = None
177+
178+
# Look for first version entry or end of header
179+
for i, line in enumerate(lines):
180+
if line.startswith("## ["):
181+
insert_index = i
182+
break
183+
184+
if insert_index is None:
185+
# No versions yet, add at the end
186+
new_content = current_content.rstrip() + "\n\n" + new_section + "\n"
187+
else:
188+
# Insert before first version
189+
lines.insert(insert_index, new_section)
190+
lines.insert(insert_index + 1, "") # Add blank line
191+
new_content = "\n".join(lines)
192+
193+
# Write updated changelog
194+
with open(CHANGELOG_FILE, "w") as f:
195+
f.write(new_content)
196+
197+
return True
198+
199+
200+
@click.command()
201+
@click.option("--dry-run", is_flag=True, help="Show what would be added without making changes")
202+
@click.option("--date", help="Override the date (YYYY-MM-DD format)")
203+
def main(dry_run: bool, date: Optional[str]):
204+
"""Generate changelog from processed changesets."""
205+
206+
click.echo(click.style("📜 Generating changelog...\n", fg="cyan", bold=True))
207+
208+
config = load_config()
209+
data = load_changeset_data()
210+
211+
if not data:
212+
click.echo(click.style("No changeset data found. Run version script first!", fg="red"))
213+
return
214+
215+
# Override date if provided
216+
if date:
217+
data["date"] = date
218+
219+
# Generate changelog section
220+
new_section = generate_version_section(data, config)
221+
222+
click.echo(click.style("Generated changelog entry:", fg="green"))
223+
click.echo("-" * 60)
224+
click.echo(new_section)
225+
click.echo("-" * 60)
226+
227+
if dry_run:
228+
click.echo(click.style("\n🔍 Dry run - no changes made", fg="yellow"))
229+
return
230+
231+
# Update changelog file
232+
if update_changelog(new_section, data["version"]):
233+
click.echo(click.style(f"\n✅ Updated {CHANGELOG_FILE}", fg="green", bold=True))
234+
235+
# Clean up data file
236+
data_file = CHANGESET_DIR / ".changeset-data.json"
237+
if data_file.exists():
238+
os.remove(data_file)
239+
else:
240+
click.echo(click.style("\n❌ Failed to update changelog", fg="red"))
241+
return
242+
243+
# Show next steps
244+
click.echo(click.style("\n📝 Next steps:", fg="yellow"))
245+
click.echo(" 1. Review the updated CHANGELOG.md")
246+
click.echo(" 2. Commit the version and changelog changes")
247+
click.echo(" 3. Create a pull request for the release")
248+
249+
250+
if __name__ == "__main__":
251+
main()

0 commit comments

Comments
 (0)