Skip to content

Commit 55b1ca2

Browse files
committed
[build noble] try diff in release notes
1 parent 34aff6e commit 55b1ca2

2 files changed

Lines changed: 323 additions & 0 deletions

File tree

.github/workflows/build-and-push-noble.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,34 @@ jobs:
6363
run: tar -czf snapshot-${{ env.SNAPSHOT_NAME }}.tar.gz -C noble/snapshots ${{ env.SNAPSHOT_NAME }}
6464
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/v') || contains(env.COMMIT_MESSAGE, '[build noble]')
6565

66+
- name: Find previous snapshot URL
67+
id: prev_snapshot
68+
run: |
69+
URL=$(gh api "repos/${{ github.repository }}/releases" \
70+
--jq 'map(select(.tag_name | startswith("noble-ros2-linuxv5-"))) | first | .assets[] | select(.name | startswith("snapshot-")) | .browser_download_url' \
71+
2>/dev/null || true)
72+
echo "url=${URL}" >> "$GITHUB_OUTPUT"
73+
env:
74+
GH_TOKEN: ${{ github.token }}
75+
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/v') || contains(env.COMMIT_MESSAGE, '[build noble]')
76+
77+
- name: Compute release diff
78+
run: |
79+
PREV="${{ steps.prev_snapshot.outputs.url }}"
80+
if [[ -n "$PREV" ]]; then
81+
python noble/compute_release_diff.py "$PREV" "snapshot-${{ env.SNAPSHOT_NAME }}.tar.gz" > release-notes.txt || true
82+
else
83+
echo "First snapshot – no previous release to diff against." > release-notes.txt
84+
fi
85+
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/v') || contains(env.COMMIT_MESSAGE, '[build noble]')
86+
6687
- name: Upload snapshot to GitHub Release
6788
uses: softprops/action-gh-release@v2
6889
with:
6990
tag_name: ${{ env.SNAPSHOT_NAME }}
7091
name: ${{ env.SNAPSHOT_NAME }}
7192
files: snapshot-${{ env.SNAPSHOT_NAME }}.tar.gz
93+
body_path: release-notes.txt
7294
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/v') || contains(env.COMMIT_MESSAGE, '[build noble]')
7395

7496
- name: Commit snapshot to snapshots branch

noble/compute_release_diff.py

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
#!/usr/bin/env python3
2+
"""
3+
compute_release_diff.py
4+
5+
Download two snapshot tarballs from GitHub Releases and produce a human-readable
6+
diff of apt, pip, and forest packages across all image variants (base/robot/rt).
7+
8+
Usage:
9+
python compute_release_diff.py <url_old> <url_new>
10+
"""
11+
12+
import sys
13+
import tarfile
14+
import tempfile
15+
import urllib.request
16+
from pathlib import Path
17+
18+
19+
# ---------------------------------------------------------------------------
20+
# Parsing helpers
21+
# ---------------------------------------------------------------------------
22+
23+
def parse_apt(text: str) -> dict[str, str]:
24+
"""Parse 'package=version' lines into {package: version}."""
25+
result = {}
26+
for line in text.splitlines():
27+
line = line.strip()
28+
if not line:
29+
continue
30+
if "=" in line:
31+
pkg, _, ver = line.partition("=")
32+
result[pkg.strip()] = ver.strip()
33+
return result
34+
35+
36+
def parse_pip(text: str) -> dict[str, str]:
37+
"""Parse 'package==version' lines (pip freeze format) into {package: version}."""
38+
result = {}
39+
for line in text.splitlines():
40+
line = line.strip()
41+
if not line or line.startswith("#"):
42+
continue
43+
if "==" in line:
44+
pkg, _, ver = line.partition("==")
45+
result[pkg.strip().lower()] = ver.strip()
46+
elif " @ " in line:
47+
# editable / URL installs: keep as-is with empty version marker
48+
pkg = line.split(" @ ")[0].strip().lower()
49+
result[pkg] = line.split(" @ ", 1)[1].strip()
50+
return result
51+
52+
53+
def parse_forest(text: str) -> dict[str, str]:
54+
"""Parse 'package: commit_hash' lines into {package: hash}."""
55+
result = {}
56+
for line in text.splitlines():
57+
line = line.strip()
58+
if not line or line.startswith("#") or "not found" in line.lower():
59+
continue
60+
if ":" in line:
61+
pkg, _, val = line.partition(":")
62+
result[pkg.strip()] = val.strip()
63+
return result
64+
65+
66+
# ---------------------------------------------------------------------------
67+
# Diff helpers
68+
# ---------------------------------------------------------------------------
69+
70+
def diff_packages(
71+
old: dict[str, str],
72+
new: dict[str, str],
73+
) -> tuple[dict, dict, dict]:
74+
"""
75+
Return (added, removed, changed) dictionaries.
76+
added: {pkg: new_version}
77+
removed: {pkg: old_version}
78+
changed: {pkg: (old_version, new_version)}
79+
"""
80+
added = {p: v for p, v in new.items() if p not in old}
81+
removed = {p: v for p, v in old.items() if p not in new}
82+
changed = {
83+
p: (old[p], new[p])
84+
for p in old
85+
if p in new and old[p] != new[p]
86+
}
87+
return added, removed, changed
88+
89+
90+
# ---------------------------------------------------------------------------
91+
# Download / extract helpers
92+
# ---------------------------------------------------------------------------
93+
94+
def download_and_extract(url_or_path: str, dest: Path) -> None:
95+
"""Download a .tar.gz from *url_or_path* (HTTP URL or local path) and extract into *dest*."""
96+
local = Path(url_or_path)
97+
if local.exists():
98+
print(f" Extracting local file {url_or_path} ...", flush=True)
99+
with tarfile.open(local, "r:gz") as tf:
100+
tf.extractall(dest)
101+
return
102+
103+
print(f" Downloading {url_or_path} ...", flush=True)
104+
with urllib.request.urlopen(url_or_path) as resp:
105+
data = resp.read()
106+
print(f" Downloaded {len(data) // 1024} KiB, extracting...", flush=True)
107+
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
108+
tmp.write(data)
109+
tmp_path = Path(tmp.name)
110+
with tarfile.open(tmp_path, "r:gz") as tf:
111+
tf.extractall(dest)
112+
tmp_path.unlink()
113+
114+
115+
def read_snapshot(root: Path) -> dict[str, dict[str, dict[str, str]]]:
116+
"""
117+
Walk the extracted snapshot directory and return a nested dict:
118+
{variant: {manager: {package: version}}}
119+
where variant in {base, robot, rt} and manager in {apt, pip, forest}.
120+
"""
121+
snapshot: dict[str, dict[str, dict[str, str]]] = {}
122+
for variant_dir in sorted(root.iterdir()):
123+
if not variant_dir.is_dir():
124+
continue
125+
variant = variant_dir.name
126+
snapshot[variant] = {}
127+
128+
apt_file = variant_dir / "apt.txt"
129+
if apt_file.exists():
130+
snapshot[variant]["apt"] = parse_apt(apt_file.read_text())
131+
132+
pip_file = variant_dir / "pip.txt"
133+
if pip_file.exists():
134+
snapshot[variant]["pip"] = parse_pip(pip_file.read_text())
135+
136+
forest_file = variant_dir / "forest.lock"
137+
if forest_file.exists():
138+
snapshot[variant]["forest"] = parse_forest(forest_file.read_text())
139+
140+
return snapshot
141+
142+
143+
# ---------------------------------------------------------------------------
144+
# Report formatting
145+
# ---------------------------------------------------------------------------
146+
147+
def format_section(
148+
title: str,
149+
added: dict,
150+
removed: dict,
151+
changed: dict,
152+
) -> list[str]:
153+
lines: list[str] = []
154+
if not added and not removed and not changed:
155+
lines.append(f" {title}: no changes")
156+
return lines
157+
158+
lines.append(f" {title}:")
159+
160+
if added:
161+
lines.append(f" + Added ({len(added)}):")
162+
for pkg in sorted(added):
163+
lines.append(f" {pkg}{added[pkg]}")
164+
165+
if removed:
166+
lines.append(f" - Removed ({len(removed)}):")
167+
for pkg in sorted(removed):
168+
lines.append(f" {pkg} {removed[pkg]}")
169+
170+
if changed:
171+
lines.append(f" ~ Changed ({len(changed)}):")
172+
for pkg in sorted(changed):
173+
old_v, new_v = changed[pkg]
174+
lines.append(f" {pkg} {old_v}{new_v}")
175+
176+
return lines
177+
178+
179+
def print_diff(
180+
old_snap: dict[str, dict[str, dict[str, str]]],
181+
new_snap: dict[str, dict[str, dict[str, str]]],
182+
old_label: str,
183+
new_label: str,
184+
) -> None:
185+
managers = ["apt", "pip", "forest"]
186+
manager_parsers = {"apt": "APT packages", "pip": "Pip packages", "forest": "Forest packages"}
187+
variants = sorted(set(old_snap) | set(new_snap))
188+
189+
print()
190+
print("=" * 72)
191+
print(f" Snapshot diff")
192+
print(f" OLD: {old_label}")
193+
print(f" NEW: {new_label}")
194+
print("=" * 72)
195+
196+
for variant in variants:
197+
print()
198+
print(f"┌─ Variant: {variant} " + "─" * max(0, 60 - len(variant)))
199+
200+
old_v = old_snap.get(variant, {})
201+
new_v = new_snap.get(variant, {})
202+
203+
any_change = False
204+
for mgr in managers:
205+
old_pkgs = old_v.get(mgr, {})
206+
new_pkgs = new_v.get(mgr, {})
207+
if not old_pkgs and not new_pkgs:
208+
continue
209+
added, removed, changed = diff_packages(old_pkgs, new_pkgs)
210+
section_lines = format_section(manager_parsers[mgr], added, removed, changed)
211+
for l in section_lines:
212+
print(l)
213+
if added or removed or changed:
214+
any_change = True
215+
216+
if not any_change:
217+
print(" (no changes)")
218+
219+
print()
220+
print("=" * 72)
221+
222+
223+
# ---------------------------------------------------------------------------
224+
# Summary statistics
225+
# ---------------------------------------------------------------------------
226+
227+
def print_summary(
228+
old_snap: dict,
229+
new_snap: dict,
230+
) -> None:
231+
print()
232+
print("Summary statistics:")
233+
variants = sorted(set(old_snap) | set(new_snap))
234+
for variant in variants:
235+
old_v = old_snap.get(variant, {})
236+
new_v = new_snap.get(variant, {})
237+
row_parts = []
238+
for mgr in ["apt", "pip", "forest"]:
239+
old_pkgs = old_v.get(mgr, {})
240+
new_pkgs = new_v.get(mgr, {})
241+
if not old_pkgs and not new_pkgs:
242+
continue
243+
added, removed, changed = diff_packages(old_pkgs, new_pkgs)
244+
row_parts.append(
245+
f"{mgr}: +{len(added)}/-{len(removed)}/~{len(changed)}"
246+
)
247+
print(f" {variant:10s} " + " ".join(row_parts))
248+
print()
249+
250+
251+
# ---------------------------------------------------------------------------
252+
# Entry point
253+
# ---------------------------------------------------------------------------
254+
255+
def label_from_url(url: str) -> str:
256+
return url.split("/")[-1].replace(".tar.gz", "")
257+
258+
259+
def main() -> None:
260+
if len(sys.argv) != 3:
261+
print("Usage: compute_release_diff.py <url_old> <url_new>")
262+
sys.exit(1)
263+
264+
url_old, url_new = sys.argv[1], sys.argv[2]
265+
old_label = label_from_url(url_old)
266+
new_label = label_from_url(url_new)
267+
268+
with tempfile.TemporaryDirectory() as tmp:
269+
tmp_path = Path(tmp)
270+
old_dir = tmp_path / "old"
271+
new_dir = tmp_path / "new"
272+
old_dir.mkdir()
273+
new_dir.mkdir()
274+
275+
print(f"Fetching OLD snapshot: {old_label}")
276+
download_and_extract(url_old, old_dir)
277+
278+
print(f"Fetching NEW snapshot: {new_label}")
279+
download_and_extract(url_new, new_dir)
280+
281+
# The tarball may place files inside a single top-level directory.
282+
# Detect and unwrap that if present.
283+
def unwrap(d: Path) -> Path:
284+
children = [c for c in d.iterdir()]
285+
if len(children) == 1 and children[0].is_dir():
286+
return children[0]
287+
return d
288+
289+
old_root = unwrap(old_dir)
290+
new_root = unwrap(new_dir)
291+
292+
print("Parsing snapshots...", flush=True)
293+
old_snap = read_snapshot(old_root)
294+
new_snap = read_snapshot(new_root)
295+
296+
print_diff(old_snap, new_snap, old_label, new_label)
297+
print_summary(old_snap, new_snap)
298+
299+
300+
if __name__ == "__main__":
301+
main()

0 commit comments

Comments
 (0)