1+ #!/usr/bin/env python3
2+
3+ import json
4+ import os
5+ import re
6+ import subprocess
7+ import sys
8+ import urllib .request
9+ import urllib .error
10+ from datetime import datetime , timezone
11+
12+ import yaml
13+
14+ VARS_FILE = "build/ansible/vars/tool-versions.yml"
15+ USER_AGENT = "tool-version-checker (+https://github.com)"
16+
17+ def http_get_json (url , headers = None ):
18+ req = urllib .request .Request (url , headers = {"User-Agent" : USER_AGENT , ** (headers or {})})
19+ with urllib .request .urlopen (req , timeout = 30 ) as resp :
20+ return json .loads (resp .read ().decode ())
21+
22+ def http_get_text (url , headers = None ):
23+ req = urllib .request .Request (url , headers = {"User-Agent" : USER_AGENT , ** (headers or {})})
24+ with urllib .request .urlopen (req , timeout = 30 ) as resp :
25+ return resp .read ().decode ()
26+
27+ def github_latest_release_tag (repo ):
28+ headers = {}
29+ token = os .environ .get ("GITHUB_TOKEN" )
30+ if token :
31+ headers ["Authorization" ] = f"Bearer { token } "
32+ data = http_get_json (f"https://api.github.com/repos/{ repo } /releases/latest" , headers = headers )
33+ return data ["tag_name" ]
34+
35+ def strip_v_prefix (tag ):
36+ return tag [1 :] if tag .lower ().startswith ("v" ) else tag
37+
38+ def semver_tuple (v ):
39+ parts = re .findall (r"\d+" , v )
40+ return tuple (int (p ) for p in parts ) if parts else (0 ,)
41+
42+
43+ # ---------------------------------------------------------------------------
44+ # Per-tool checkers
45+ # ---------------------------------------------------------------------------
46+
47+ def check_singularity (current ):
48+ tag = github_latest_release_tag ("sylabs/singularity" )
49+ latest = strip_v_prefix (tag )
50+ if semver_tuple (latest ) > semver_tuple (current ):
51+ return latest , f"https://github.com/sylabs/singularity/releases/tag/{ tag } "
52+ return None , None
53+
54+ def check_shpc (current ):
55+ tag = github_latest_release_tag ("singularityhub/singularity-hpc" )
56+ latest = strip_v_prefix (tag )
57+ if semver_tuple (latest ) > semver_tuple (current ):
58+ return latest , f"https://github.com/singularityhub/singularity-hpc/releases/tag/{ tag } "
59+ return None , None
60+
61+ def check_go (current ):
62+ data = http_get_json ("https://go.dev/dl/?mode=json" )
63+ stable = [d for d in data if d .get ("stable" )]
64+ if not stable :
65+ return None , None
66+ latest_tag = stable [0 ]["version" ]
67+ latest = latest_tag [2 :] if latest_tag .startswith ("go" ) else latest_tag
68+ if semver_tuple (latest ) > semver_tuple (current ):
69+ return latest , "https://go.dev/dl/"
70+ return None , None
71+
72+ def check_nextflow (current ):
73+ tag = github_latest_release_tag ("nextflow-io/nextflow" )
74+ latest = strip_v_prefix (tag )
75+ if semver_tuple (latest ) > semver_tuple (current ):
76+ return latest , f"https://github.com/nextflow-io/nextflow/releases/tag/{ tag } "
77+ return None , None
78+
79+ def check_nfcore (current ):
80+ data = http_get_json ("https://pypi.org/pypi/nf-core/json" )
81+ latest = data ["info" ]["version" ]
82+ if semver_tuple (latest ) > semver_tuple (current ):
83+ return latest , "https://pypi.org/project/nf-core/"
84+ return None , None
85+
86+ RSTUDIO_VERSION_PATTERN = re .compile (r"^\d{4}\.\d{1,2}\.\d+[+-]\d+$" )
87+
88+ def find_rstudio_version_in_json (data ):
89+ if isinstance (data , str ):
90+ return data if RSTUDIO_VERSION_PATTERN .match (data ) else None
91+ if isinstance (data , dict ):
92+ for value in data .values ():
93+ found = find_rstudio_version_in_json (value )
94+ if found :
95+ return found
96+ elif isinstance (data , list ):
97+ for item in data :
98+ found = find_rstudio_version_in_json (item )
99+ if found :
100+ return found
101+ return None
102+
103+
104+ def check_rstudio (current_full ):
105+ data = http_get_json ("https://www.rstudio.com/wp-content/downloads.json" )
106+ version_full_raw = find_rstudio_version_in_json (data )
107+
108+ if version_full_raw is None :
109+ shape = list (data .keys ()) if isinstance (data , dict ) else type (data ).__name__
110+ raise RuntimeError (
111+ f"could not find an RStudio version string in downloads.json response "
112+ f"(top-level shape: { shape } )"
113+ )
114+
115+ version_full = version_full_raw .replace ("+" , "-" )
116+ version_short = version_full .split ("-" )[0 ]
117+
118+ if semver_tuple (version_full ) > semver_tuple (current_full ):
119+ return {"rstudio_version_full" : version_full , "rstudio_version" : version_short }, \
120+ "https://www.rstudio.com/wp-content/downloads.json"
121+ return None , None
122+
123+
124+ def check_apt_package (package_name , current ):
125+ result = subprocess .run (
126+ ["apt-cache" , "madison" , package_name ],
127+ capture_output = True , text = True , timeout = 30 ,
128+ )
129+ if result .returncode != 0 or not result .stdout .strip ():
130+ raise RuntimeError (f"apt-cache madison { package_name } failed: { result .stderr .strip ()} " )
131+
132+ first_line = result .stdout .strip ().splitlines ()[0 ]
133+ pkg_version = [p .strip () for p in first_line .split ("|" )][1 ]
134+
135+ latest = pkg_version .split ("-" )[0 ]
136+
137+ if semver_tuple (latest ) > semver_tuple (current ):
138+ return latest , f"apt-cache madison { package_name } (ubuntu noble/universe)"
139+ return None , None
140+
141+ def check_r (current ):
142+ return check_apt_package ("r-base" , current )
143+
144+ def check_snakemake_apt (current ):
145+ return check_apt_package ("snakemake" , current )
146+
147+ def check_jupyter (current ):
148+ now_stamp = datetime .now (timezone .utc ).strftime ("%Y.%m" )
149+ if now_stamp != current :
150+ return now_stamp , "(timestamp - not an upstream version)"
151+ return None , None
152+
153+
154+ CHECKS = [
155+ ("Singularity" , "singularity_version" , check_singularity ),
156+ ("shpc" , "shpc_version" , check_shpc ),
157+ ("Go" , "go_version" , check_go ),
158+ ("Nextflow" , "nextflow_version" , check_nextflow ),
159+ ("nf-core" , "nfcore_version" , check_nfcore ),
160+ ("RStudio" , "rstudio_version_full" , check_rstudio ),
161+ ("Snakemake (apt)" , "snakemake_version" , check_snakemake_apt ),
162+ ("R (apt)" , "r_version" , check_r ),
163+ ("Jupyter (timestamp)" , "jupyter_version" , check_jupyter ),
164+ ]
165+
166+
167+ def main ():
168+ with open (VARS_FILE ) as f :
169+ raw_text = f .read ()
170+ tool_vars = yaml .safe_load (raw_text )
171+
172+ changes = [] # list of dicts: {label, key, old, new, source}
173+ errors = [] # list of (label, error message)
174+
175+ for label , key , checker in CHECKS :
176+ current = tool_vars .get (key )
177+ if current is None :
178+ errors .append ((label , f"key '{ key } ' not found in { VARS_FILE } " ))
179+ continue
180+ try :
181+ new_value , source = checker (current )
182+ except (urllib .error .URLError , urllib .error .HTTPError , KeyError ,
183+ json .JSONDecodeError , RuntimeError , subprocess .SubprocessError ) as e :
184+ errors .append ((label , str (e )))
185+ continue
186+
187+ if new_value is None :
188+ continue
189+
190+ if isinstance (new_value , dict ):
191+ # multi-key update (RStudio: full + short)
192+ for sub_key , sub_new in new_value .items ():
193+ old = tool_vars .get (sub_key )
194+ if old != sub_new :
195+ changes .append ({"label" : label , "key" : sub_key , "old" : old , "new" : sub_new , "source" : source })
196+ else :
197+ old = tool_vars .get (key )
198+ if old != new_value :
199+ changes .append ({"label" : label , "key" : key , "old" : old , "new" : new_value , "source" : source })
200+
201+ updated_text = raw_text
202+ for change in changes :
203+ pattern = re .compile (
204+ rf'^({ re .escape (change ["key" ])} [ \t]*:[ \t]*)"?{ re .escape (str (change ["old" ]))} "?[ \t]*$' ,
205+ re .MULTILINE ,
206+ )
207+ replacement = f'{ change ["key" ]} : "{ change ["new" ]} "'
208+ new_text , count = pattern .subn (replacement , updated_text )
209+ if count == 0 :
210+ key_name = change ["key" ]
211+ old_val = change ["old" ]
212+ errors .append ((change ["label" ], f"could not locate '{ key_name } : { old_val } ' in file to replace" ))
213+ continue
214+ updated_text = new_text
215+
216+ if changes :
217+ with open (VARS_FILE , "w" ) as f :
218+ f .write (updated_text )
219+
220+ # Write outputs for the GitHub Actions workflow to consume
221+ with open ("version_check_summary.md" , "w" ) as f :
222+ if changes :
223+ f .write ("## Tool version updates found\n \n " )
224+ f .write ("| Tool | Variable | Old | New | Source |\n " )
225+ f .write ("|---|---|---|---|---|\n " )
226+ for c in changes :
227+ f .write (f"| { c ['label' ]} | `{ c ['key' ]} ` | `{ c ['old' ]} ` | `{ c ['new' ]} ` | { c ['source' ]} |\n " )
228+ else :
229+ f .write ("No tool version updates found.\n " )
230+
231+ if errors :
232+ f .write ("\n ### Checks that could not complete\n \n " )
233+ for label , msg in errors :
234+ f .write (f"- **{ label } **: { msg } \n " )
235+
236+ f .write ("\n ### Tools intentionally not auto-checked\n \n " )
237+ f .write ("- `java_version` - major LTS selector, not an auto-bump target.\n " )
238+
239+ # Set a GitHub Actions output so the workflow knows whether to open a PR
240+ gha_output = sys .argv [1 ] if len (sys .argv ) > 1 else None
241+ if gha_output :
242+ with open (gha_output , "a" ) as f :
243+ f .write (f"changes_found={ 'true' if changes else 'false' } \n " )
244+
245+ print (f"Checked { len (CHECKS )} tools: { len (changes )} updates found, { len (errors )} errors." )
246+ for c in changes :
247+ print (f" { c ['label' ]} : { c ['key' ]} { c ['old' ]} -> { c ['new' ]} " )
248+ for label , msg in errors :
249+ print (f" [error] { label } : { msg } " , file = sys .stderr )
250+
251+
252+ if __name__ == "__main__" :
253+ main ()
0 commit comments