Skip to main content

#!/usr/bin/env python3 """ CODITECT Release Preparation Script

Automates the release process:

  1. Bumps VERSION file (major, minor, patch, or specific version)
  2. Updates CHANGELOG.md with release date
  3. Creates git commit and tag
  4. Optionally uploads version.json to GCS

Usage: python3 scripts/prepare-release.py patch # 1.0.0 -> 1.0.1 python3 scripts/prepare-release.py minor # 1.0.0 -> 1.1.0 python3 scripts/prepare-release.py major # 1.0.0 -> 2.0.0 python3 scripts/prepare-release.py 1.2.3 # Specific version python3 scripts/prepare-release.py patch --pilot # 1.0.0 -> 1.0.1-pilot.1 python3 scripts/prepare-release.py --dry-run # Preview changes python3 scripts/prepare-release.py --upload-only # Just upload version.json

Task: C.11.5.1 ADR: ADR-066 Update & Uninstall Mechanism """

import argparse import json import os import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Optional, Tuple

Configuration

SCRIPT_DIR = Path(file).parent REPO_ROOT = SCRIPT_DIR.parent VERSION_FILE = REPO_ROOT / "VERSION" CHANGELOG_FILE = REPO_ROOT / "CHANGELOG.md" GCS_BUCKET = "gs://coditect-dist" VERSION_JSON_PATH = f"{GCS_BUCKET}/version.json"

def run_command(cmd: str, check: bool = True, capture: bool = True) -> str: """Run a shell command and return output.""" result = subprocess.run( cmd, shell=True, capture_output=capture, text=True, cwd=REPO_ROOT ) if check and result.returncode != 0: print(f"❌ Command failed: {cmd}") print(f" Error: {result.stderr}") sys.exit(1) return result.stdout.strip() if capture else ""

def get_current_version() -> str: """Read current version from VERSION file.""" if not VERSION_FILE.exists(): print("❌ VERSION file not found") sys.exit(1) return VERSION_FILE.read_text().strip()

def parse_version(version: str) -> Tuple[int, int, int, Optional[str]]: """ Parse version string into components.

Returns: (major, minor, patch, prerelease)
Examples:
"1.0.0" -> (1, 0, 0, None)
"1.0.0-pilot.1" -> (1, 0, 0, "pilot.1")
"""
match = re.match(r'^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$', version)
if not match:
print(f"❌ Invalid version format: {version}")
sys.exit(1)

major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
prerelease = match.group(4)
return major, minor, patch, prerelease

def bump_version(current: str, bump_type: str, pilot: bool = False) -> str: """ Bump version according to semver rules.

Args:
current: Current version string
bump_type: 'major', 'minor', 'patch', or specific version
pilot: If True, add -pilot.N suffix
"""
# If bump_type looks like a version number, use it directly
if re.match(r'^\d+\.\d+\.\d+', bump_type):
new_version = bump_type
else:
major, minor, patch, _ = parse_version(current)

if bump_type == "major":
major += 1
minor = 0
patch = 0
elif bump_type == "minor":
minor += 1
patch = 0
elif bump_type == "patch":
patch += 1
else:
print(f"❌ Unknown bump type: {bump_type}")
sys.exit(1)

new_version = f"{major}.{minor}.{patch}"

# Add pilot suffix if requested
if pilot:
# Check if there's already a pilot version with this base
existing_pilots = run_command(
f"git tag -l 'v{new_version}-pilot.*' | wc -l",
check=False
).strip()
pilot_num = int(existing_pilots) + 1 if existing_pilots else 1
new_version = f"{new_version}-pilot.{pilot_num}"

return new_version

def update_changelog(version: str, dry_run: bool = False) -> bool: """ Update CHANGELOG.md with release date.

Looks for "[Unreleased]" or "## [x.x.x]" header and updates it.
"""
if not CHANGELOG_FILE.exists():
print("⚠️ CHANGELOG.md not found, skipping")
return False

content = CHANGELOG_FILE.read_text()
date_str = datetime.now().strftime("%Y-%m-%d")

# Replace [Unreleased] with version and date
updated = re.sub(
r'## \[Unreleased\]',
f'## [{version}] - {date_str}\n\n## [Unreleased]',
content
)

# If no Unreleased section, try to find the version header
if updated == content:
updated = re.sub(
rf'## \[{re.escape(version)}\](\s*)$',
f'## [{version}] - {date_str}',
content,
flags=re.MULTILINE
)

if updated != content:
if not dry_run:
CHANGELOG_FILE.write_text(updated)
print(f"✅ Updated CHANGELOG.md with {version} - {date_str}")
return True
else:
print("⚠️ No changes made to CHANGELOG.md")
return False

def create_version_json(version: str) -> dict: """Generate version.json content.""" is_prerelease = "-" in version return { "latest": version, "released": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "prerelease": is_prerelease, "changelog_url": f"https://github.com/coditect-ai/coditect-core/releases/tag/v{version}", "update_message": f"CODITECT v{version} is now available", "min_supported": "1.0.0", "download_url": f"https://github.com/coditect-ai/coditect-core/releases/tag/v{version}" }

def upload_version_json(version: str, dry_run: bool = False) -> bool: """Upload version.json to GCS.""" version_data = create_version_json(version)

# Write temporary file
temp_file = REPO_ROOT / "version.json.tmp"
temp_file.write_text(json.dumps(version_data, indent=2))

print(f"\n📤 Uploading version.json to GCS:")
print(json.dumps(version_data, indent=2))

if dry_run:
print("\n🔍 DRY RUN - would upload to:", VERSION_JSON_PATH)
temp_file.unlink()
return True

# Check gcloud auth
auth_check = run_command("gcloud auth print-identity-token 2>/dev/null", check=False)
if not auth_check:
print("⚠️ Not authenticated to GCS. Run: gcloud auth login")
temp_file.unlink()
return False

# Upload
try:
run_command(f"gcloud storage cp {temp_file} {VERSION_JSON_PATH}")
print(f"✅ Uploaded to {VERSION_JSON_PATH}")
temp_file.unlink()
return True
except Exception as e:
print(f"❌ Upload failed: {e}")
temp_file.unlink()
return False

def create_release_commit(version: str, dry_run: bool = False) -> bool: """Create release commit and tag.""" # Check for uncommitted changes status = run_command("git status --porcelain") if "VERSION" not in status and "CHANGELOG" not in status: print("⚠️ No changes to commit") return False

if dry_run:
print(f"\n🔍 DRY RUN - would create:")
print(f" Commit: 'chore(release): v{version}'")
print(f" Tag: v{version}")
return True

# Add and commit
run_command("git add VERSION CHANGELOG.md")
run_command(f'git commit -m "chore(release): v{version}"')
print(f"✅ Created commit: chore(release): v{version}")

# Create tag
run_command(f'git tag -a v{version} -m "Release v{version}"')
print(f"✅ Created tag: v{version}")

return True

def print_checklist(version: str, dry_run: bool): """Print release checklist.""" print("\n" + "═" * 60) print("📋 CODITECT RELEASE CHECKLIST") print("═" * 60)

if dry_run:
print("\n🔍 DRY RUN MODE - No changes will be made\n")

print(f"""
  1. ✅ Version bump: {get_current_version()} → {version}
  2. ✅ CHANGELOG.md updated
  3. ✅ Git commit created
  4. ✅ Git tag created: v{version}

📌 NEXT STEPS:

  1. Review changes: git show HEAD
  2. Push to trigger release: git push origin main --tags
  3. Monitor GitHub Actions: https://github.com/coditect-ai/coditect-core/actions
  4. Verify version.json: curl -s https://storage.googleapis.com/coditect-dist/version.json | jq .

⚠️ ROLLBACK (if needed): git reset --hard HEAD~1 git tag -d v{version} """)

def main(): parser = argparse.ArgumentParser( description="CODITECT Release Preparation Script", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s patch # Bump patch version (1.0.0 -> 1.0.1) %(prog)s minor # Bump minor version (1.0.0 -> 1.1.0) %(prog)s major # Bump major version (1.0.0 -> 2.0.0) %(prog)s 2.0.0 # Set specific version %(prog)s patch --pilot # Create pilot release (1.0.1-pilot.1) %(prog)s --dry-run patch # Preview what would happen %(prog)s --upload-only # Just upload current version.json """ )

parser.add_argument(
"bump_type",
nargs="?",
default="patch",
help="Version bump type: major, minor, patch, or specific version"
)
parser.add_argument(
"--pilot",
action="store_true",
help="Create pilot (pre-release) version"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without making them"
)
parser.add_argument(
"--upload-only",
action="store_true",
help="Only upload version.json to GCS (skip version bump)"
)
parser.add_argument(
"--no-upload",
action="store_true",
help="Skip uploading version.json to GCS"
)
parser.add_argument(
"--no-commit",
action="store_true",
help="Skip creating git commit and tag"
)

args = parser.parse_args()

print("\n🚀 CODITECT Release Preparation")
print("━" * 40)

current_version = get_current_version()
print(f"📌 Current version: {current_version}")

# Upload-only mode
if args.upload_only:
print(f"\n📤 Uploading version.json for v{current_version}...")
success = upload_version_json(current_version, args.dry_run)
sys.exit(0 if success else 1)

# Calculate new version
new_version = bump_version(current_version, args.bump_type, args.pilot)
print(f"📌 New version: {new_version}")

if args.dry_run:
print("\n🔍 DRY RUN MODE - No changes will be made")

# Step 1: Update VERSION file
print(f"\n1️⃣ Updating VERSION file...")
if not args.dry_run:
VERSION_FILE.write_text(new_version + "\n")
print(f" {current_version} → {new_version}")

# Step 2: Update CHANGELOG
print(f"\n2️⃣ Updating CHANGELOG.md...")
update_changelog(new_version, args.dry_run)

# Step 3: Create commit and tag
if not args.no_commit:
print(f"\n3️⃣ Creating git commit and tag...")
create_release_commit(new_version, args.dry_run)

# Step 4: Upload version.json
if not args.no_upload:
print(f"\n4️⃣ Uploading version.json to GCS...")
upload_version_json(new_version, args.dry_run)

# Print checklist
print_checklist(new_version, args.dry_run)

if args.dry_run:
print("\n✅ Dry run complete. Run without --dry-run to make changes.")
else:
print("\n✅ Release preparation complete!")

if name == "main": main()