Skip to content

Commit 5d94c7d

Browse files
authored
SNOW-2331878: auto release branch preparation (#3764)
1 parent c31c297 commit 5d94c7d

File tree

3 files changed

+739
-0
lines changed

3 files changed

+739
-0
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ repos:
8484
- --per-file-ignores=tests/*.py:T201 # prints are allowed in test files
8585
# Ignore errors for generated protocol buffer stubs
8686
- --exclude=src/snowflake/snowpark/_internal/proto/generated/ast_pb2.py
87+
- --exclude=scripts/release/prepare_release_branch.py
8788
# Use mypy for static type checking.
8889
- repo: https://github.com/pre-commit/mirrors-mypy
8990
rev: 'v0.991'
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
#
2+
# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved.
3+
#
4+
5+
import os
6+
import sys
7+
import re
8+
import subprocess
9+
import argparse
10+
from datetime import date, datetime
11+
import glob
12+
13+
14+
def setup_argument_parser():
15+
"""Set up command line argument parser"""
16+
parser = argparse.ArgumentParser(
17+
description="Snowpark Python Release Preparation Script",
18+
epilog="""
19+
Examples:
20+
# Interactive mode (no arguments - prompts for input)
21+
python prepare_release_branch.py
22+
23+
# Non-interactive mode (any arguments provided - requires --version)
24+
python prepare_release_branch.py --version 1.39.0 --release-date 2024-12-31 --base-ref origin/main
25+
26+
# Non-interactive mode with minimal options (uses defaults for date and base-ref)
27+
python prepare_release_branch.py --version 1.39.0
28+
29+
# Use a specific commit as base
30+
python prepare_release_branch.py --version 1.39.0 --base-ref abc1234
31+
32+
# Use custom release date with version
33+
python prepare_release_branch.py --version 1.39.0 --release-date 2024-12-31
34+
""",
35+
formatter_class=argparse.RawDescriptionHelpFormatter,
36+
)
37+
38+
parser.add_argument(
39+
"--version",
40+
help="Release version in format x.y.z (e.g., 1.39.0). Required in non-interactive mode.",
41+
type=str,
42+
)
43+
44+
parser.add_argument(
45+
"--release-date",
46+
help="Release date in format YYYY-MM-DD (default: today)",
47+
type=str,
48+
)
49+
50+
parser.add_argument(
51+
"--base-ref",
52+
help="Base reference for release branch - commit ID, branch name, or origin/main (default: origin/main)",
53+
type=str,
54+
default="origin/main",
55+
)
56+
57+
return parser
58+
59+
60+
def check_snowpark_directory():
61+
"""Check if we're in a snowpark-python directory"""
62+
current_dir = os.getcwd()
63+
if "snowpark-python" not in current_dir:
64+
print("Error: This script must be run from within a snowpark-python directory")
65+
sys.exit(1)
66+
67+
68+
def validate_version(version_str):
69+
"""Validate version format and return parsed components"""
70+
if not version_str:
71+
return None
72+
73+
# Validate version format (x.y.z)
74+
if not re.match(r"^\d+\.\d+\.\d+$", version_str):
75+
print("Error: Version must be in format x.y.z (e.g., 1.39.0)")
76+
sys.exit(1)
77+
78+
parts = version_str.split(".")
79+
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
80+
81+
return version_str, major, minor, patch
82+
83+
84+
def validate_date(date_str):
85+
"""Validate date format and return formatted date"""
86+
if not date_str:
87+
return date.today().strftime("%Y-%m-%d")
88+
89+
# Validate date format (YYYY-MM-DD)
90+
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
91+
print("Error: Date must be in format YYYY-MM-DD (e.g., 2024-12-31)")
92+
sys.exit(1)
93+
94+
# Validate that it's a valid date
95+
try:
96+
datetime.strptime(date_str, "%Y-%m-%d")
97+
except ValueError:
98+
print("Error: Invalid date. Please enter a valid date in format YYYY-MM-DD")
99+
sys.exit(1)
100+
101+
return date_str
102+
103+
104+
def validate_base_ref(base_ref):
105+
"""Validate and format base reference"""
106+
if not base_ref or base_ref == "origin/main":
107+
return "origin/main", "origin/main"
108+
elif re.match(r"^[a-fA-F0-9]{7,40}$", base_ref):
109+
return base_ref, f"commit {base_ref}"
110+
else:
111+
return base_ref, f"branch {base_ref}"
112+
113+
114+
def get_version_input(args=None, is_interactive=True):
115+
"""Get version input from CLI args or user and validate format"""
116+
if args and args.version:
117+
return validate_version(args.version)
118+
119+
if not is_interactive:
120+
# This shouldn't happen as we validate --version is required in non-interactive mode
121+
print("Error: Version is required in non-interactive mode")
122+
sys.exit(1)
123+
124+
print("Enter the next release version (format: x.y.z, e.g., 1.39.0)")
125+
version_str = input("Version: ").strip()
126+
return validate_version(version_str)
127+
128+
129+
def get_base_reference(args=None, is_interactive=True):
130+
"""Get optional base commit/branch for the release branch from CLI args or user input"""
131+
if args and args.base_ref:
132+
return validate_base_ref(args.base_ref)
133+
134+
if not is_interactive:
135+
# Use default value in non-interactive mode
136+
return validate_base_ref("origin/main")
137+
138+
print("\nOptional: Specify a base for the release branch")
139+
print("- Enter a commit ID (e.g., abc1234)")
140+
print("- Enter a local branch name (e.g., feature-branch)")
141+
print("- Press Enter to use origin/main (default)")
142+
base_ref = input("Base reference (default: origin/main): ").strip()
143+
144+
return validate_base_ref(base_ref)
145+
146+
147+
def get_release_date_input(args=None, is_interactive=True):
148+
"""Get optional release date from CLI args or user input and validate format"""
149+
if args and args.release_date:
150+
return validate_date(args.release_date)
151+
152+
if not is_interactive:
153+
# Use today's date as default in non-interactive mode
154+
return validate_date(None) # None will default to today
155+
156+
print("\nOptional: Specify a release date (format: YYYY-MM-DD)")
157+
print("- Press Enter to use today's date (default)")
158+
date_str = input("Release date (default: today): ").strip()
159+
160+
return validate_date(date_str)
161+
162+
163+
def run_git_command(command):
164+
"""Run git command and handle errors"""
165+
try:
166+
result = subprocess.run(
167+
command, shell=True, check=True, capture_output=True, text=True
168+
)
169+
return result.stdout
170+
except subprocess.CalledProcessError as e:
171+
print(f"Git command failed: {command}")
172+
print(f"Error: {e.stderr}")
173+
sys.exit(1)
174+
175+
176+
def update_version_py(version_str, major, minor, patch):
177+
"""Update src/snowflake/snowpark/version.py"""
178+
version_file = "src/snowflake/snowpark/version.py"
179+
180+
if not os.path.exists(version_file):
181+
print(f"Error: {version_file} not found")
182+
sys.exit(1)
183+
184+
with open(version_file) as f:
185+
content = f.read()
186+
187+
# Replace VERSION tuple
188+
new_content = re.sub(
189+
r"VERSION = \(\d+, \d+, \d+\)",
190+
f"VERSION = ({major}, {minor}, {patch})",
191+
content,
192+
)
193+
194+
with open(version_file, "w") as f:
195+
f.write(new_content)
196+
197+
print(f"✓ Updated {version_file}")
198+
199+
200+
def update_meta_yaml(version_str):
201+
"""Update recipe/meta.yaml"""
202+
meta_file = "recipe/meta.yaml"
203+
204+
if not os.path.exists(meta_file):
205+
print(f"Error: {meta_file} not found")
206+
sys.exit(1)
207+
208+
with open(meta_file) as f:
209+
content = f.read()
210+
211+
# Replace version string
212+
new_content = re.sub(
213+
r'{% set version = "[^"]*" %}',
214+
f'{{% set version = "{version_str}" %}}',
215+
content,
216+
)
217+
218+
with open(meta_file, "w") as f:
219+
f.write(new_content)
220+
221+
print(f"✓ Updated {meta_file}")
222+
223+
224+
def update_changelog(version_str, release_date):
225+
"""Update CHANGELOG.md by replacing the version date with the specified date"""
226+
changelog_file = "CHANGELOG.md"
227+
228+
if not os.path.exists(changelog_file):
229+
print(f"Error: {changelog_file} not found")
230+
sys.exit(1)
231+
232+
with open(changelog_file) as f:
233+
content = f.read()
234+
235+
# Find the first version line and replace it with the new version and specified date
236+
# Pattern matches the first: ## x.y.z (any_date)
237+
first_version_pattern = r"## \d+\.\d+\.\d+ \([^)]+\)"
238+
replacement = f"## {version_str} ({release_date})"
239+
240+
new_content = re.sub(first_version_pattern, replacement, content, count=1)
241+
242+
if new_content == content:
243+
print(f"Warning: No version entry found in {changelog_file}")
244+
else:
245+
with open(changelog_file, "w") as f:
246+
f.write(new_content)
247+
print(f"✓ Updated {changelog_file}")
248+
249+
250+
def update_test_files(major, minor, patch):
251+
"""Update all .test and .test.DISABLED files in tests/ast/data"""
252+
test_dir = "tests/ast/data"
253+
254+
if not os.path.exists(test_dir):
255+
print(f"Error: {test_dir} not found")
256+
sys.exit(1)
257+
258+
# Get both .test and .test.DISABLED files
259+
test_files = glob.glob(os.path.join(test_dir, "*.test"))
260+
test_files.extend(glob.glob(os.path.join(test_dir, "*.test.DISABLED")))
261+
262+
if not test_files:
263+
print(f"Warning: No .test or .test.DISABLED files found in {test_dir}")
264+
return
265+
266+
# Determine client_version format based on patch version
267+
if patch == 0:
268+
# Major/minor release - only major and minor fields
269+
client_version_replacement = f"""client_version {{
270+
major: {major}
271+
minor: {minor}
272+
}}"""
273+
else:
274+
# Patch release - major, minor, and patch fields
275+
client_version_replacement = f"""client_version {{
276+
major: {major}
277+
minor: {minor}
278+
patch: {patch}
279+
}}"""
280+
281+
updated_count = 0
282+
283+
for test_file in test_files:
284+
with open(test_file) as f:
285+
content = f.read()
286+
287+
# Replace client_version blocks
288+
# This regex handles both formats (with and without patch)
289+
pattern = r"client_version\s*\{\s*major:\s*\d+\s*minor:\s*\d+\s*(?:patch:\s*\d+\s*)?\}"
290+
291+
if re.search(pattern, content):
292+
new_content = re.sub(pattern, client_version_replacement, content)
293+
294+
if new_content != content:
295+
with open(test_file, "w") as f:
296+
f.write(new_content)
297+
updated_count += 1
298+
299+
print(f"✓ Updated {updated_count} .test and .test.DISABLED files in {test_dir}")
300+
301+
302+
def main():
303+
# Parse command line arguments
304+
parser = setup_argument_parser()
305+
args = parser.parse_args()
306+
307+
# Determine if we're in interactive or non-interactive mode
308+
# Non-interactive mode: any command-line arguments provided
309+
# Interactive mode: no command-line arguments (just script name)
310+
is_interactive = len(sys.argv) == 1
311+
312+
# In non-interactive mode, --version is required
313+
if not is_interactive and not args.version:
314+
print("Error: --version is required when using non-interactive mode")
315+
sys.exit(1)
316+
317+
if is_interactive:
318+
print("Snowpark Python Release Preparation Script")
319+
print("==========================================")
320+
else:
321+
print("Snowpark Python Release Preparation Script (Non-Interactive Mode)")
322+
print("==================================================================")
323+
324+
# Check if we're in the right directory
325+
check_snowpark_directory()
326+
327+
# Get version input
328+
version_result = get_version_input(args, is_interactive)
329+
if not version_result:
330+
print("Error: Version is required")
331+
sys.exit(1)
332+
version_str, major, minor, patch = version_result
333+
334+
# Get release date
335+
release_date = get_release_date_input(args, is_interactive)
336+
if is_interactive or args.release_date:
337+
print(f"\nRelease date: {release_date}")
338+
339+
print(f"\nPreparing release for version {version_str}")
340+
print(f"Major: {major}, Minor: {minor}, Patch: {patch}")
341+
342+
is_patch_release = patch != 0
343+
print(f"Release type: {'Patch' if is_patch_release else 'Major/Minor'}")
344+
345+
# Get base reference for release branch
346+
base_ref, base_description = get_base_reference(args, is_interactive)
347+
348+
# Checkout release branch
349+
branch_name = f"release-v{version_str}"
350+
print(f"\n🔀 Creating release branch: {branch_name} from {base_description}")
351+
352+
if base_ref == "origin/main":
353+
# Fetch origin/main and create branch from it
354+
print("Fetching latest origin/main...")
355+
run_git_command("git fetch origin main")
356+
run_git_command(f"git checkout -b {branch_name} origin/main")
357+
else:
358+
# Create branch from specified commit or branch
359+
run_git_command(f"git checkout -b {branch_name} {base_ref}")
360+
361+
print("\n📝 Updating version files...")
362+
363+
# Update files
364+
update_version_py(version_str, major, minor, patch)
365+
update_meta_yaml(version_str)
366+
update_changelog(version_str, release_date)
367+
update_test_files(major, minor, patch)
368+
369+
print(
370+
f"\n🎉 Release preparation completed successfully!"
371+
f"\n\nNext steps:"
372+
f"\n1. Sync the dependency updates in version.py and meta.yaml if they are inconsistent"
373+
f"\n2. Review the changes with: git diff"
374+
f"\n3. Commit the changes with: git commit -am 'Prepare release v{version_str}'"
375+
f"\n4. Push the branch with: git push origin {branch_name}"
376+
)
377+
378+
379+
if __name__ == "__main__":
380+
main()

0 commit comments

Comments
 (0)