Skip to content

Commit b6a0f5c

Browse files
authored
feat(changelogs): Use SBOMs for standardized package data extraction (#4635)
1 parent 5e0438a commit b6a0f5c

6 files changed

Lines changed: 153 additions & 38 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

.github/workflows/reusable-build.yml

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -146,22 +146,15 @@ jobs:
146146
if: github.event_name != 'pull_request'
147147
id: generate-sbom
148148
env:
149-
IMAGE: ${{ env.IMAGE_NAME }}
150-
STREAM_NAME: ${{ matrix.stream_name }}
149+
MATRIX_BASE_NAME: ${{ matrix.base_name }}
150+
MATRIX_STREAM_NAME: ${{ matrix.stream_name }}
151+
MATRIX_IMAGE_FLAVOR: ${{ matrix.image_flavor }}
151152
SYFT_CMD: ${{ steps.setup-syft.outputs.cmd }}
152-
OCI_DIR: "/tmp/image-oci-dir"
153153
run: |
154-
mkdir -p ${OCI_DIR}/rootfs
155-
sudo podman container create --replace --name "${IMAGE}" "localhost/${IMAGE}:${STREAM_NAME}"
156-
sudo podman export "${IMAGE}" | sudo tar -C ${OCI_DIR}/rootfs -xf -
157-
sudo podman container rm "${IMAGE}"
158-
159-
SBOM="$(mktemp -d)/sbom.json"
160-
export SYFT_PARALLELISM=$(($(nproc)*2))
161-
sudo $SYFT_CMD --source-name "${IMAGE}"-"${STREAM_NAME}" ${OCI_DIR} -o syft-json=${SBOM}
162-
du -sh ${SBOM}
163-
echo "SBOM=${SBOM}" >> $GITHUB_OUTPUT
164-
sudo rm -rf ${OCI_DIR}
154+
sudo -E $(command -v just) gen-sbom "${MATRIX_BASE_NAME}" \
155+
"${MATRIX_STREAM_NAME}" \
156+
"${MATRIX_IMAGE_FLAVOR}" \
157+
"${SYFT_CMD}"
165158
166159
- name: Rechunk Image
167160
id: rechunk-image
@@ -288,8 +281,10 @@ jobs:
288281
env:
289282
IMAGE: ${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }}
290283
DIGEST: ${{ steps.push.outputs.digest }}
291-
SBOM: ${{ steps.generate-sbom.outputs.SBOM }}
284+
IMAGE_NAME: ${{ env.IMAGE_NAME }}
292285
run: |
286+
SBOM="sbom_out/${IMAGE_NAME}/sbom.json"
287+
293288
cd "$(dirname "${SBOM}")"
294289
oras attach \
295290
--artifact-type application/vnd.spdx+json \

.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)