1+ # .github/scripts/bump_dependants.py
2+ """
3+ Bumps the core package version in all dependant projects and opens a PR for each.
4+ Handles both direct and optional/extra dependencies in requirements.txt and pyproject.toml.
5+ Only updates entries using == or ~= specifiers (leaves unpinned or ranged deps alone).
6+ """
7+
8+ import argparse
9+ import re
10+ import os
11+ import subprocess
12+ import sys
13+ from pathlib import Path
14+
15+ SUPPORTED_SPECIFIERS = ("==" , "~=" )
16+
17+
18+ def parse_args ():
19+ parser = argparse .ArgumentParser ()
20+ parser .add_argument ("--version" , required = True , help = "New version, e.g. 1.5.0" )
21+ parser .add_argument ("--package" , required = True , help = "Core package name" )
22+ parser .add_argument ("--dependants" , nargs = "+" , required = True , help = "Project directories to update" )
23+ return parser .parse_args ()
24+
25+
26+ def bump_requirements_txt (path : Path , package : str , new_version : str ) -> bool :
27+ """
28+ Matches lines like:
29+ package==1.2.3
30+ package~=1.2
31+ package[extra1,extra2]==1.2.3
32+ package[extra1,extra2]~=1.2
33+ Leaves unpinned or range-pinned (>=, <=, !=) entries untouched.
34+ """
35+ pattern = re .compile (
36+ rf"^({ re .escape (package )} (?:\[[^\]]*\])?)({ '|' .join (re .escape (s ) for s in SUPPORTED_SPECIFIERS )} )[^\s#]+" ,
37+ re .MULTILINE ,
38+ )
39+ original = path .read_text ()
40+ updated , count = pattern .subn (rf"\g<1>~={ new_version } " , original )
41+ if count :
42+ path .write_text (updated )
43+ return bool (count )
44+
45+
46+ def bump_pyproject_toml (path : Path , package : str , new_version : str ) -> bool :
47+ """
48+ Matches PEP 508 strings in pyproject.toml, covering:
49+ - [project] dependencies
50+ - [project.optional-dependencies] groups
51+ - [tool.poetry.dependencies] and similar
52+ e.g. 'your-core-library==1.2.3', 'your-core-library[opt]~=1.2'
53+ Uses regex rather than a TOML parser to preserve file formatting.
54+ """
55+ pattern = re .compile (
56+ rf'({ re .escape (package )} (?:\[[^\]]*\])?)({ "|" .join (re .escape (s ) for s in SUPPORTED_SPECIFIERS )} )[^\s,"\']+' ,
57+ )
58+ original = path .read_text ()
59+ updated , count = pattern .subn (rf"\g<1>~={ new_version } " , original )
60+ if count :
61+ path .write_text (updated )
62+ return bool (count )
63+
64+
65+ def bump_project (project_dir : Path , package : str , new_version : str ) -> list [Path ]:
66+ """Returns list of files that were modified."""
67+ modified = []
68+
69+ candidates = {
70+ "requirements.txt" : bump_requirements_txt ,
71+ "requirements-dev.txt" : bump_requirements_txt ,
72+ "pyproject.toml" : bump_pyproject_toml ,
73+ }
74+
75+ for filename , bump_fn in candidates .items ():
76+ fpath = project_dir / filename
77+ if fpath .exists () and bump_fn (fpath , package , new_version ):
78+ modified .append (fpath )
79+
80+ return modified
81+
82+
83+ def run (cmd : list [str ], ** kwargs ) -> subprocess .CompletedProcess :
84+ print (f" $ { ' ' .join (cmd )} " )
85+ return subprocess .run (cmd , check = True , ** kwargs )
86+
87+
88+ def open_pr (branch : str , title : str , body : str , base : str = "main" ):
89+ # Guard against a PR already being open for this branch
90+ result = subprocess .run (
91+ ["gh" , "pr" , "list" , "--head" , branch , "--json" , "number" ],
92+ capture_output = True , text = True , check = True ,
93+ )
94+ if '"number"' in result .stdout :
95+ print (f" PR already open for { branch } , skipping" )
96+ return
97+
98+ run (["gh" , "pr" , "create" ,
99+ "--title" , title ,
100+ "--body" , body ,
101+ "--base" , base ,
102+ "--head" , branch ,
103+ "--label" , "dependencies" ])
104+
105+
106+ def main ():
107+ args = parse_args ()
108+ package = args .package
109+ new_version = args .version
110+ repo_root = Path (__file__ ).resolve ().parents [2 ]
111+
112+ run (["git" , "config" , "user.name" , "github-actions[bot]" ])
113+ run (["git" , "config" , "user.email" , "github-actions[bot]@users.noreply.github.com" ])
114+
115+ for dependant in args .dependants :
116+ dependant = dependant .strip ().removesuffix (os .path .sep ).strip ()
117+ print (f"\n Processing: { dependant } " )
118+ project_dir = repo_root / dependant
119+ if not project_dir .is_dir ():
120+ print (f" Directory not found, skipping" )
121+ continue
122+
123+ modified = bump_project (project_dir , package , new_version )
124+ if not modified :
125+ print (f" No pinned { package } dependency found, skipping" )
126+ continue
127+
128+ branch = f"chore/bump-{ package } -{ new_version } -in-{ dependant } "
129+ run (["git" , "checkout" , "-b" , branch ])
130+
131+ for fpath in modified :
132+ run (["git" , "add" , str (fpath )])
133+
134+ run (["git" , "commit" , "-m" , f"chore({ dependant } ): bump { package } to { new_version } " ])
135+ run (["git" , "push" , "origin" , branch ])
136+
137+ open_pr (
138+ branch = branch ,
139+ title = f"chore({ dependant } ): bump { package } to ~={ new_version } " ,
140+ body = (
141+ f"Automated minor version bump of `{ package } ` to `~={ new_version } ` "
142+ f"following the upstream release.\n \n "
143+ f"Files updated:\n "
144+ + "\n " .join (f"- `{ f .relative_to (repo_root )} `" for f in modified )
145+ ),
146+ )
147+
148+ run (["git" , "checkout" , "main" ])
149+
150+ print ("\n Done." )
151+
152+
153+ if __name__ == "__main__" :
154+ sys .exit (main ())
0 commit comments