-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpublish.py
More file actions
executable file
·428 lines (356 loc) · 13.4 KB
/
publish.py
File metadata and controls
executable file
·428 lines (356 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "packaging==23.1",
# ]
# ///
"""Manage version bumps and releases for treefmt-pre-commit.
This script:
- Reads the latest git tag to determine the current version
- Increments the 4th digit (tool version) automatically
- Updates pyproject.toml, README.md, and .pre-commit-config.yaml from templates
- Creates a git commit and annotated tag
- Optionally pushes changes to remote
The version scheme is MAJOR.MINOR.PATCH.TOOL_VERSION where:
- First 3 digits: treefmt binary version (managed by mirror.py)
- 4th digit: tool wrapper version (managed by this script)
Templates are stored in version-templates/ with {version} placeholders.
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
from packaging.version import Version
def main():
parser = argparse.ArgumentParser(
description="Bump version and create release tag",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Auto-increment 4th digit from latest tag
%(prog)s --version 2.4.0.10 # Manually specify version
%(prog)s --dry-run # Preview changes without committing
%(prog)s --push # Also push commit and tag to remote
%(prog)s --no-commit # Update files only, no git operations
""",
)
parser.add_argument(
"--version",
help="Manually specify version (must be X.Y.Z.R format with 4 parts)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without making them",
)
parser.add_argument(
"--push",
action="store_true",
help="Push commit and tag to remote after creating them",
)
parser.add_argument(
"--no-commit",
action="store_true",
help="Update files only, don't create commit or tag",
)
args = parser.parse_args()
if args.push and args.no_commit:
print("Error: --push and --no-commit are mutually exclusive", file=sys.stderr)
sys.exit(1)
# Verify git working directory is clean (unless --no-commit)
if not args.no_commit and not args.dry_run:
if not check_git_clean():
print(
"Error: Git working directory is not clean. "
"Commit or stash changes first.",
file=sys.stderr,
)
sys.exit(1)
# Determine target version
if args.version:
if not validate_version(args.version):
print(
f"Error: Invalid version format '{args.version}'. "
"Must be X.Y.Z.R with 4 parts.",
file=sys.stderr,
)
sys.exit(1)
target_version = args.version
else:
current_version = get_latest_tag_version()
if current_version is None:
print(
"Error: No existing tags found. Use --version to specify initial version.",
file=sys.stderr,
)
sys.exit(1)
target_version = increment_version(current_version)
# Check if tag already exists
if not args.dry_run and tag_exists(target_version):
print(f"Error: Tag v{target_version} already exists.", file=sys.stderr)
sys.exit(1)
print(f"Target version: {target_version}")
# Check for manual changes to managed files (unless dry-run or no-commit)
if not args.dry_run and not args.no_commit:
current_version = get_latest_tag_version()
if current_version is not None:
files_with_changes = check_for_manual_changes(current_version)
if files_with_changes:
print(
"\nError: The following files have manual changes that would be lost:",
file=sys.stderr,
)
for file in sorted(files_with_changes):
print(f" - {file}", file=sys.stderr)
print(
"\nThese files are managed by templates in version-templates/",
file=sys.stderr,
)
print(
"Please update the corresponding template files instead of editing them directly.",
file=sys.stderr,
)
print(
"\nTo fix this:",
file=sys.stderr,
)
print(
" 1. Copy your changes to the corresponding file in version-templates/",
file=sys.stderr,
)
print(
" 2. Run this script again",
file=sys.stderr,
)
sys.exit(1)
# Discover template files
template_dir = Path("version-templates")
template_files = []
for root, dirs, files in os.walk(template_dir):
for filename in files:
template_path = Path(root) / filename
rel_path = template_path.relative_to(template_dir)
template_files.append(str(rel_path))
if args.dry_run:
print("\nDry run - would update the following files:")
for file in sorted(template_files):
print(f" - {file}")
print(f"\nWould create commit: 'Bump version to {target_version}'")
print(f"Would create tag: v{target_version}")
if args.push:
print("Would push commit and tag to remote")
return
# Update all files from templates
updated_files = update_template_files(target_version)
print("\nUpdated files:")
for file in sorted(updated_files):
print(f" ✓ {file}")
# Git operations
if not args.no_commit:
create_commit(target_version, updated_files)
print(f"\n✓ Created commit: 'Bump version to {target_version}'")
create_tag(target_version)
print(f"✓ Created tag: v{target_version}")
if args.push:
push_changes(target_version)
print("\n✓ Pushed commit and tag to remote")
else:
print(
f"\nTo push changes, run:\n"
f" git push origin main\n"
f" git push origin v{target_version}"
)
else:
print("\nFiles updated. Skipped git commit and tag creation.")
def get_latest_tag_version() -> str | None:
"""Get the latest version from git tags.
Returns version string like "2.4.0.4" or None if no tags exist.
Handles both 3-digit (from mirror.py) and 4-digit (from publish.py) versions.
"""
try:
result = subprocess.run(
["git", "tag", "-l"],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
print(f"Error: Failed to get git tags: {e}", file=sys.stderr)
sys.exit(1)
tags = result.stdout.strip().split("\n")
if not tags or tags == [""]:
return None
versions = []
for tag in tags:
# Remove 'v' prefix if present
version_str = tag.lstrip("v")
try:
# Use packaging.Version for robust parsing
versions.append(Version(version_str))
except Exception:
# Skip invalid version tags
continue
if not versions:
return None
# Get the highest version
latest = max(versions)
version_str = str(latest)
# Ensure we have 4 parts (handle 3-digit versions from mirror.py)
parts = version_str.split(".")
if len(parts) == 3:
# This is from mirror.py, needs .0 appended for first tool version
version_str = f"{version_str}.0"
return version_str
def increment_version(current_version: str) -> str:
"""Increment the 4th digit (tool version).
Args:
current_version: Version string like "2.4.0.4"
Returns:
Incremented version like "2.4.0.5"
"""
parts = current_version.split(".")
if len(parts) != 4:
print(
f"Error: Cannot increment version '{current_version}' - must have 4 parts",
file=sys.stderr,
)
sys.exit(1)
parts[3] = str(int(parts[3]) + 1)
return ".".join(parts)
def validate_version(version: str) -> bool:
"""Check if version is valid X.Y.Z.R format with 4 parts."""
parts = version.split(".")
if len(parts) != 4:
return False
try:
for part in parts:
if int(part) < 0:
return False
except ValueError:
return False
return True
def check_git_clean() -> bool:
"""Check if git working directory is clean."""
try:
result = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip() == ""
except subprocess.CalledProcessError:
return False
def tag_exists(version: str) -> bool:
"""Check if a git tag already exists for this version."""
try:
result = subprocess.run(
["git", "tag", "-l", f"v{version}"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip() != ""
except subprocess.CalledProcessError:
return False
def check_for_manual_changes(current_version: str) -> list[str]:
"""Check if managed files have manual changes that would be lost.
Generates files from templates using the current version and compares
them to actual file content. This detects manual changes that weren't
made to the templates.
Args:
current_version: Current version string like "2.4.0.15"
Returns:
List of file paths that have unexpected manual changes
"""
template_dir = Path("version-templates")
files_with_changes = []
for root, dirs, files in os.walk(template_dir):
for filename in files:
template_path = Path(root) / filename
# Get relative path from template_dir
rel_path = template_path.relative_to(template_dir)
# Output to same relative path in main directory
output_path = Path(rel_path.with_suffix(""))
# Skip if the actual file doesn't exist yet
if not output_path.exists():
continue
# Read template and replace version placeholder with CURRENT version
template_content = template_path.read_text()
expected_content = template_content.replace("{version}", current_version)
# Read actual file content
actual_content = output_path.read_text()
# Compare normalized content (to handle line ending differences)
expected_normalized = expected_content.replace("\r\n", "\n")
actual_normalized = actual_content.replace("\r\n", "\n")
if expected_normalized != actual_normalized:
files_with_changes.append(str(output_path))
return files_with_changes
def update_template_files(version: str) -> list[str]:
"""Update all files from version-templates directory.
Walks through version-templates/ and for each file:
- Reads the template
- Replaces {version} placeholder
- Writes to corresponding path in main directory
Returns:
List of updated file paths (relative to repo root)
"""
template_dir = Path("version-templates")
updated_files = []
for root, dirs, files in os.walk(template_dir):
for filename in files:
template_path = Path(root) / filename
# Get relative path from template_dir
rel_path = template_path.relative_to(template_dir)
# Output to same relative path in main directory
output_path = Path(rel_path.with_suffix(""))
# Read template and replace version placeholder
content = template_path.read_text()
new_content = content.replace("{version}", version)
# Write to output file
output_path.write_text(new_content)
updated_files.append(str(output_path))
return updated_files
def create_commit(version: str, files: list[str]):
"""Create git commit with updated files."""
try:
subprocess.run(["git", "add", *files], check=True)
subprocess.run(
["git", "commit", "-m", f"Bump version to {version}"],
check=True,
)
except subprocess.CalledProcessError as e:
print(f"Error: Failed to create commit: {e}", file=sys.stderr)
sys.exit(1)
def create_tag(version: str):
"""Create annotated git tag."""
try:
subprocess.run(
["git", "tag", "-a", f"v{version}", "-m", f"Release {version}"],
check=True,
)
except subprocess.CalledProcessError as e:
print(f"Error: Failed to create tag: {e}", file=sys.stderr)
sys.exit(1)
def push_changes(version: str):
"""Push commit and tag to remote."""
try:
# Get current branch name
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True,
text=True,
check=True,
)
branch = result.stdout.strip()
# Push commit
subprocess.run(["git", "push", "origin", branch], check=True)
# Push tag
subprocess.run(["git", "push", "origin", f"v{version}"], check=True)
except subprocess.CalledProcessError as e:
print(f"Error: Failed to push changes: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()