Skip to content

Commit 67b14d3

Browse files
authored
Merge pull request #12 from AustralianBioCommons/actions-update-versions
Add tool and image versions update function
2 parents b4b0e40 + 5aaae18 commit 67b14d3

8 files changed

Lines changed: 511 additions & 1 deletion

File tree

.github/pull_request_template.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
## Description
2+
3+
<!-- What does this PR do? Why is this change needed? -->
4+
5+
6+
## Type of change
7+
8+
<!-- Check all that apply -->
9+
10+
- [ ] Bug fix
11+
- [ ] New feature / tool addition
12+
- [ ] Version update (tool or image)
13+
- [ ] Configuration change (Ansible, Packer, OpenStack)
14+
- [ ] Documentation only
15+
- [ ] Other: <!-- describe -->
16+
17+
---
18+
19+
## Version updates
20+
21+
<!-- If this PR updates any tool versions in build/ansible/vars/tool-versions.yml,
22+
list them here. If no version changes, write "N/A". -->
23+
24+
| Tool | Old version | New version |
25+
|------|-------------|-------------|
26+
| | | |
27+
28+
**Image version** (`build/openstack-bioshell.pkr.hcl`)
29+
- [ ] Bumped `image_version` to reflect changes in this PR
30+
- [ ] No image version bump needed (docs/config only)
31+
32+
---
33+
34+
## Changelog
35+
36+
- [ ] Updated `CHANGELOG.md` with a summary of changes under the correct version heading
37+
- [ ] No changelog entry needed (e.g. internal refactor, typo fix)
38+
39+
---
40+
41+
## Checklist
42+
43+
- [ ] My changes follow the existing conventions in this repo
44+
- [ ] I have reviewed the diff and removed any unintended changes
45+
- [ ] Any new tools or version pins are documented in the relevant Ansible vars file
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env python3
2+
3+
import re
4+
import sys
5+
6+
PKR_FILE = "build/openstack-bioshell.pkr.hcl"
7+
8+
9+
def bump_minor(version):
10+
major, minor, patch = (int(p) for p in version.split("."))
11+
return f"{major}.{minor + 1}.0"
12+
13+
14+
def main():
15+
with open(PKR_FILE) as f:
16+
text = f.read()
17+
18+
pattern = re.compile(
19+
r'(variable\s+"image_version"\s*{\s*type\s*=\s*string\s*default\s*=\s*")'
20+
r'(\d+\.\d+\.\d+)'
21+
r'(")',
22+
re.DOTALL,
23+
)
24+
25+
match = pattern.search(text)
26+
if not match:
27+
print(f"Could not find image_version variable block in {PKR_FILE}", file=sys.stderr)
28+
sys.exit(1)
29+
30+
old_version = match.group(2)
31+
new_version = bump_minor(old_version)
32+
33+
new_text = pattern.sub(lambda m: f"{m.group(1)}{new_version}{m.group(3)}", text, count=1)
34+
35+
with open(PKR_FILE, "w") as f:
36+
f.write(new_text)
37+
38+
print(f"image_version: {old_version} -> {new_version}")
39+
40+
gha_output = sys.argv[1] if len(sys.argv) > 1 else None
41+
if gha_output:
42+
with open(gha_output, "a") as f:
43+
f.write(f"old_image_version={old_version}\n")
44+
f.write(f"new_image_version={new_version}\n")
45+
46+
47+
if __name__ == "__main__":
48+
main()

.github/scripts/check-versions.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)