Skip to content

Commit 37bc248

Browse files
committed
feat(changelogs): Use SBOMs for standardized package data extraction
Refactors the changelog generation script to fetch package information from image Software Bill of Materials (SBOMs) instead of custom labels. This is split out of #4564 to derisk the change
1 parent 9ba27cf commit 37bc248

5 files changed

Lines changed: 143 additions & 23 deletions

File tree

.github/changelogs.py

Lines changed: 107 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from itertools import product
22
import subprocess
33
import json
4+
import os
5+
import tempfile
46
import time
57
from typing import Any
68
import re
79
from collections import defaultdict
810

9-
REGISTRY = "docker://ghcr.io/ublue-os/"
11+
REGISTRY = "ghcr.io/ublue-os/"
1012

1113
IMAGE_MATRIX_LATEST = {
1214
"experience": ["base", "dx"],
@@ -22,6 +24,7 @@
2224
RETRIES = 3
2325
RETRY_WAIT = 5
2426
FEDORA_PATTERN = re.compile(r"\.fc\d\d")
27+
EPOCH_PATTERN = re.compile(r"^\d+:")
2528
START_PATTERN = lambda target: re.compile(rf"{target}-\d\d\d+")
2629

2730
PATTERN_ADD = "\n| ✨ | {name} | | {version} |"
@@ -125,7 +128,7 @@ def get_manifests(target: str):
125128
for i in range(RETRIES):
126129
try:
127130
output = subprocess.run(
128-
["skopeo", "inspect", REGISTRY + img + ":" + target],
131+
["skopeo", "inspect", f"docker://{REGISTRY}{img}:{target}"],
129132
check=True,
130133
stdout=subprocess.PIPE,
131134
).stdout
@@ -165,24 +168,104 @@ def get_tags(target: str, manifests: dict[str, Any]):
165168
return tags[-2], tags[-1]
166169

167170

168-
def get_packages(manifests: dict[str, Any]):
171+
def get_image_digest(image: str, tag: str) -> str:
172+
"""Get image digest using skopeo."""
173+
result = subprocess.run(
174+
["skopeo", "inspect", f"docker://{image}:{tag}"],
175+
capture_output=True,
176+
text=True,
177+
check=True
178+
)
179+
return json.loads(result.stdout)["Digest"]
180+
181+
182+
def get_sbom(image: str, digest: str) -> dict:
183+
"""Fetch SBOM using ORAS."""
184+
full_ref = f"{image}@{digest}"
185+
186+
# Find the SBOM referrer attached to this image
187+
result = subprocess.run(
188+
["oras", "discover", "--format", "json", full_ref],
189+
capture_output=True,
190+
text=True,
191+
check=True,
192+
)
193+
discovered = json.loads(result.stdout)
194+
195+
sbom_digest = None
196+
for referrer in discovered.get("referrers", []):
197+
if "spdx+json" in referrer.get("artifactType", ""):
198+
sbom_digest = referrer["digest"]
199+
break
200+
201+
if sbom_digest is None:
202+
raise RuntimeError(f"No SBOM referrer found for {full_ref}")
203+
204+
sbom_ref = f"{image}@{sbom_digest}"
205+
with tempfile.TemporaryDirectory() as tmpdir:
206+
subprocess.run(
207+
["oras", "pull", sbom_ref],
208+
capture_output=True,
209+
check=True,
210+
cwd=tmpdir,
211+
)
212+
for fname in os.listdir(tmpdir):
213+
fpath = os.path.join(tmpdir, fname)
214+
if fname.endswith(".zst"):
215+
result = subprocess.run(
216+
["zstd", "-d", fpath, "--stdout"],
217+
capture_output=True,
218+
check=True,
219+
)
220+
return json.loads(result.stdout)
221+
elif fname.endswith(".json"):
222+
with open(fpath) as f:
223+
return json.load(f)
224+
225+
raise RuntimeError(f"No SBOM file found after pulling {sbom_ref}")
226+
227+
228+
def parse_sbom_packages(sbom: dict) -> dict[str, str]:
169229
packages = {}
170-
for img, manifest in manifests.items():
230+
for artifact in sbom.get("artifacts", []):
231+
# Only process RPM packages
232+
if artifact.get("type") != "rpm":
233+
continue
234+
name = artifact.get("name")
235+
version = artifact.get("version")
236+
if name and version:
237+
# If we see the same package, keep the one with epoch (more specific)
238+
if name not in packages or (":" in version and ":" not in packages[name]):
239+
packages[name] = version
240+
return packages
241+
242+
243+
def get_packages(target: str, images: list[tuple[str, str, str, str]]):
244+
packages = {}
245+
for j, (img, _, _, _) in enumerate(images):
246+
print(f"Getting packages for {img}:{target} via SBOM ({j+1}/{len(images)})")
171247
try:
172-
packages[img] = json.loads(manifest["Labels"]["dev.hhd.rechunk.info"])[
173-
"packages"
174-
]
248+
full_image = f"{REGISTRY}{img}"
249+
digest = get_image_digest(full_image, target)
250+
sbom = get_sbom(full_image, digest)
251+
packages[img] = parse_sbom_packages(sbom)
252+
print(f" Found {len(packages[img])} packages")
175253
except Exception as e:
176-
print(f"Failed to get packages for {img}:\n{e}")
254+
print(f" Failed to get packages for {img}:{target}: {e}")
255+
raise e
177256
return packages
178257

179258

180-
def get_package_groups(target: str, prev: dict[str, Any], manifests: dict[str, Any]):
259+
def get_package_groups(target: str, prev_tag: str, curr_tag: str):
181260
common = set()
182261
others = {k: set() for k in OTHER_NAMES.keys()}
183262

184-
npkg = get_packages(manifests)
185-
ppkg = get_packages(prev)
263+
images = list(get_images(target))
264+
265+
print(f"\nFetching current packages for {curr_tag}...")
266+
npkg = get_packages(curr_tag, images)
267+
print(f"\nFetching previous packages for {prev_tag}...")
268+
ppkg = get_packages(prev_tag, images)
186269

187270
keys = set(npkg.keys()) | set(ppkg.keys())
188271
pkg = defaultdict(set)
@@ -232,16 +315,16 @@ def get_package_groups(target: str, prev: dict[str, Any], manifests: dict[str, A
232315

233316
first = False
234317

235-
return sorted(common), {k: sorted(v) for k, v in others.items()}
318+
return sorted(common), {k: sorted(v) for k, v in others.items()}, npkg, ppkg
236319

237320

238-
def get_versions(manifests: dict[str, Any]):
321+
def get_versions(packages: dict[str, dict[str, str]]):
322+
"""Extract version info from packages dict, stripping epoch prefix and Fedora suffix."""
239323
versions = {}
240-
pkgs = get_packages(manifests)
241-
for img_pkgs in pkgs.values():
324+
for img_pkgs in packages.values():
242325
for pkg, v in img_pkgs.items():
326+
v = re.sub(EPOCH_PATTERN, "", v)
243327
v = re.sub(FEDORA_PATTERN, "", v)
244-
v = re.sub(r"\.switcheroo", "", v)
245328
versions[pkg] = v
246329
return versions
247330

@@ -338,14 +421,16 @@ def generate_changelog(
338421
target: str,
339422
pretty: str | None,
340423
workdir: str,
424+
prev_tag: str,
425+
curr_tag: str,
341426
prev_manifests,
342427
manifests,
343428
):
344-
common, others = get_package_groups(target, prev_manifests, manifests)
345-
versions = get_versions(manifests)
346-
prev_versions = get_versions(prev_manifests)
429+
common, others, curr_packages, prev_packages = get_package_groups(target, prev_tag, curr_tag)
430+
versions = get_versions(curr_packages)
431+
prev_versions = get_versions(prev_packages)
347432

348-
prev, curr = get_tags(target, manifests)
433+
prev, curr = prev_tag, curr_tag
349434

350435
if not pretty:
351436
# Generate pretty version since we dont have it
@@ -447,6 +532,8 @@ def main():
447532
target,
448533
args.pretty,
449534
args.workdir,
535+
prev,
536+
curr,
450537
prev_manifests,
451538
manifests,
452539
)

.github/renovate.json5

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
},
5555
{
5656
"matchUpdateTypes": ["pin", "digest", "pinDigest"],
57-
"matchPackageNames": ["ghcr.io/jasonn3/build-container-installer", "ghcr.io/hhd-dev/rechunk"],
57+
"matchPackageNames": ["ghcr.io/jasonn3/build-container-installer"],
5858
"automerge": false
5959
},
6060
{

.github/workflows/generate-release.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ on:
1616
options:
1717
- '["stable"]'
1818

19-
permissions:
20-
contents: write
19+
permissions: {}
2120

2221
name: Generate Release
2322
jobs:
2423
generate-release:
2524
runs-on: ubuntu-latest
25+
permissions:
26+
contents: write
2627
strategy:
2728
fail-fast: false
2829
matrix:
@@ -44,6 +45,9 @@ jobs:
4445
run: |
4546
just check
4647
48+
- name: Install ORAS
49+
uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0
50+
4751
- name: Generate Release Text
4852
id: generate-release-text
4953
shell: bash

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ previous.manifest.json
77
changelog.md
88
output.env
99
version.txt
10+
sbom_out/
1011

1112
devcontainer
1213

Justfile

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,34 @@ tag-images image_name="" default_tag="" tags="":
679679
# Show Images
680680
${PODMAN} images
681681

682+
# Extract Container and generate SBOM
683+
[group('Utility')]
684+
gen-sbom $image="bluefin" $tag="latest" $flavor="main" $syft_cmd="syft":
685+
#!/usr/bin/bash
686+
set -eoux pipefail
687+
688+
image_name=$({{ just }} image_name '{{ image }}' '{{ tag }}' '{{ flavor }}')
689+
690+
OUT_DIR="sbom_out/${image_name}"
691+
mkdir -p "${OUT_DIR}"
692+
693+
# We have to do it this stupid way because we are OOMing on github runners
694+
# https://github.com/anchore/syft/issues/3800
695+
${PODMAN} container create --replace --name ${image_name} "${image_name}:${tag}"
696+
697+
ROOTFS="${OUT_DIR}/rootfs"
698+
mkdir -p "${ROOTFS}"
699+
700+
${PODMAN} export ${image_name} | tar -C "${ROOTFS}" -xf -
701+
${PODMAN} container rm ${image_name}
702+
703+
SBOM="${OUT_DIR}/sbom.json"
704+
705+
${syft_cmd} --source-name "${image_name}:${tag}" "${OUT_DIR}" -o syft-json=${SBOM}
706+
du -sh "${SBOM}"
707+
708+
rm -rf "${ROOTFS}"
709+
682710
# DNF CI package cache
683711
[group('Utility')]
684712
setup-cache $image="bluefin" $tag="latest" $ghcr="0" $github_event="0":

0 commit comments

Comments
 (0)