Skip to content

Commit 0915039

Browse files
committed
feat: replace legacy rechunker with rpm-ostree compose build-chunked-oci
1 parent 1bda1a7 commit 0915039

8 files changed

Lines changed: 266 additions & 182 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/workflows/generate-release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ jobs:
3939
/home/linuxbrew/.linuxbrew/bin/brew install just
4040
echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH
4141
42+
- name: Install ORAS
43+
uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0
44+
4245
- name: Check Just Syntax
4346
shell: bash
4447
run: |

.github/workflows/reusable-build.yml

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -163,25 +163,19 @@ jobs:
163163
echo "SBOM=${SBOM}" >> $GITHUB_OUTPUT
164164
sudo rm -rf ${OCI_DIR}
165165
166-
- name: Rechunk Image
167-
id: rechunk-image
168-
shell: bash
166+
- name: Rechunk Image with rpm-ostree
167+
id: rechunker
169168
env:
170-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
169+
MATRIX_BASE_NAME: ${{ matrix.base_name }}
170+
MATRIX_STREAM_NAME: ${{ matrix.stream_name }}
171+
MATRIX_IMAGE_FLAVOR: ${{ matrix.image_flavor }}
172+
DEFAULT_TAG: ${{ env.DEFAULT_TAG }}
171173
run: |
172-
sudo -E $(command -v just) rechunk "${{ matrix.base_name }}" \
173-
"${{ matrix.stream_name }}" \
174-
"${{ matrix.image_flavor }}" \
174+
sudo -E $(command -v just) rechunk "${MATRIX_BASE_NAME}" \
175+
"${MATRIX_STREAM_NAME}" \
176+
"${MATRIX_IMAGE_FLAVOR}" \
175177
"1"
176178
177-
- name: Load Image into Podman
178-
id: load-rechunk
179-
shell: bash
180-
run: |
181-
sudo -E $(command -v just) load-rechunk "${{ matrix.base_name }}" \
182-
"${{ env.DEFAULT_TAG }}" \
183-
"${{ matrix.image_flavor }}"
184-
185179
- name: Secureboot Check
186180
id: secureboot
187181
shell: bash
@@ -190,6 +184,38 @@ jobs:
190184
"${{ env.DEFAULT_TAG }}" \
191185
"${{ matrix.image_flavor }}"
192186
187+
- name: Export to OCI Archive
188+
if: github.event_name == 'pull_request'
189+
id: oci-archive
190+
env:
191+
IMAGE_NAME: ${{ env.IMAGE_NAME }}
192+
DEFAULT_TAG: ${{ env.DEFAULT_TAG }}
193+
run: |
194+
sudo -E $(command -v just) export-oci "${{ matrix.base_name }}" \
195+
"${{ env.DEFAULT_TAG }}" \
196+
"${{ matrix.image_flavor }}"
197+
198+
- name: Upload OCI dir as Artifact
199+
if: github.event_name == 'pull_request'
200+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
201+
with:
202+
name: ${{ env.IMAGE_NAME }}.oci
203+
path: ${{ env.IMAGE_NAME }}.oci
204+
archive: false
205+
if-no-files-found: error
206+
retention-days: 1
207+
208+
- name: PR Testing Instructions
209+
if: github.event_name == 'pull_request'
210+
id: pr-summary
211+
env:
212+
IMAGE_NAME: ${{ env.IMAGE_NAME }}
213+
MATRIX_STREAM_NAME: "${{ matrix.stream_name }}"
214+
run: |
215+
echo "Download the .oci file" >> $GITHUB_STEP_SUMMARY
216+
echo "Rebase: sudo bootc switch --transport oci-archive /path/to/${IMAGE_NAME}.oci" >> $GITHUB_STEP_SUMMARY
217+
echo "Go back to the production image e.g.: sudo bootc switch --enforce-container-sigpolicy ghcr.io/ublue-os/${IMAGE_NAME}:${MATRIX_STREAM_NAME}" >> $GITHUB_STEP_SUMMARY
218+
193219
- name: Generate tags
194220
id: generate-tags
195221
shell: bash
@@ -225,10 +251,18 @@ jobs:
225251
with:
226252
string: ${{ env.IMAGE_REGISTRY }}
227253

254+
# TODO: remove me when we have a new podman in 26.04 runners
255+
# needed because old podman doesn't push layer annotations for
256+
# the rpm-ostree rechunker at all
257+
- name: install podman from brew
258+
if: github.event_name != 'pull_request'
259+
run: |
260+
/home/linuxbrew/.linuxbrew/bin/brew install podman
261+
228262
- name: Login to GitHub Container Registry
229263
if: github.event_name != 'pull_request'
230264
run: |
231-
echo ${{ secrets.GITHUB_TOKEN }} | podman login ghcr.io -u ${{ github.actor }} --password-stdin
265+
echo ${{ secrets.GITHUB_TOKEN }} | /home/linuxbrew/.linuxbrew/bin/podman login ghcr.io -u ${{ github.actor }} --password-stdin
232266
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
233267
234268
- name: Push to GHCR
@@ -241,9 +275,15 @@ jobs:
241275
timeout_minutes: 30
242276
command: |
243277
set -euox pipefail
278+
# HACK: push a second time so layer annotations are pushed
279+
# TODO: remove me when https://github.com/containers/podman/issues/27796 fixed
280+
281+
for tag in ${{ steps.generate-tags.outputs.alias_tags }}; do
282+
sudo -E /home/linuxbrew/.linuxbrew/bin/podman push ${{ env.IMAGE_NAME }}:${tag} ${{ steps.registry_case.outputs.lowercase }}/${{ env.IMAGE_NAME }}:${tag}
283+
done
244284
245285
for tag in ${{ steps.generate-tags.outputs.alias_tags }}; do
246-
sudo -E podman push ${{ env.IMAGE_NAME }}:${tag} ${{ steps.registry_case.outputs.lowercase }}/${{ env.IMAGE_NAME }}:${tag}
286+
sudo -E /home/linuxbrew/.linuxbrew/bin/podman push ${{ env.IMAGE_NAME }}:${tag} ${{ steps.registry_case.outputs.lowercase }}/${{ env.IMAGE_NAME }}:${tag}
247287
done
248288
249289
digest=$(skopeo inspect docker://${{ steps.registry_case.outputs.lowercase }}/${{ env.IMAGE_NAME }}:${{ env.DEFAULT_TAG }} --format '{{.Digest}}')

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
flatpaks_with_deps
22
flatpak.*
33

4-
*_build
5-
*_build.*
4+
bluefin*.oci
65
previous.manifest.json
76
changelog.md
87
output.env

0 commit comments

Comments
 (0)