1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
|
#!/usr/bin/env python3
"""Generate an SBOM/package and commit changelog for an image release."""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import tempfile
from pathlib import Path
def run(*args: str) -> str:
return subprocess.check_output(args, text=True)
def package_map(data: dict) -> dict[str, str]:
packages: dict[str, str] = {}
for artifact in data.get("artifacts", []):
if artifact.get("type") != "rpm":
continue
name = artifact.get("name")
version = artifact.get("version")
if name and version:
packages[name] = version
return packages
def image_info(image: str, tag: str) -> dict:
return json.loads(run("skopeo", "inspect", f"docker://{image}:{tag}"))
def fetch_sbom(image: str, digest: str) -> dict:
subject = f"{image}@{digest}"
discovery = json.loads(run("oras", "discover", "--format", "json", subject))
sbom_digest = next(
(
referrer["digest"]
for referrer in discovery.get("referrers", [])
if "spdx+json" in referrer.get("artifactType", "")
),
None,
)
if not sbom_digest:
raise RuntimeError(f"No SPDX SBOM is attached to {subject}")
with tempfile.TemporaryDirectory() as temp_dir:
subprocess.run(["oras", "pull", f"{image}@{sbom_digest}"], cwd=temp_dir, check=True)
for filename in os.listdir(temp_dir):
if filename.endswith(".json"):
return json.loads((Path(temp_dir) / filename).read_text())
raise RuntimeError(f"The SBOM artifact {sbom_digest} did not contain JSON")
def release_tags(image: str, channel: str) -> list[str]:
raw = run("skopeo", "list-tags", f"docker://{image}")
tags = json.loads(raw).get("Tags", [])
if channel == "stable":
pattern = re.compile(r"^\d+\.\d{8}(?:\.\d+)?$")
else:
pattern = re.compile(rf"^{re.escape(channel)}-\d+\.\d{{8}}(?:\.\d+)?$")
return sorted((tag for tag in tags if pattern.fullmatch(tag)), key=version_key)
def version_key(tag: str) -> tuple[int, int, int]:
numbers = [int(value) for value in re.findall(r"\d+", tag)]
return tuple((numbers + [0, 0, 0])[:3])
def markdown_version(value: str | None) -> str:
return value or "—"
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--image", required=True, help="registry/repository without tag")
parser.add_argument("--repository", required=True, help="GitHub owner/repository")
parser.add_argument("--current", required=True)
parser.add_argument("--channel", required=True)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--handwritten", default="")
args = parser.parse_args()
tags = release_tags(args.image, args.channel)
current_index = tags.index(args.current) if args.current in tags else len(tags)
previous = tags[current_index - 1] if current_index > 0 else None
current_info = image_info(args.image, args.current)
current_packages = package_map(fetch_sbom(args.image, current_info["Digest"]))
previous_packages: dict[str, str] | None = None
if previous:
try:
previous_packages = package_map(
fetch_sbom(args.image, image_info(args.image, previous)["Digest"])
)
except RuntimeError as error:
print(f"Warning: {error}")
changes = []
if previous_packages is not None:
for name in sorted(set(current_packages) | set(previous_packages)):
old = previous_packages.get(name)
new = current_packages.get(name)
if old != new:
changes.append((name, old, new))
previous_info = image_info(args.image, previous) if previous else {}
start_revision = previous_info.get("Labels", {}).get("org.opencontainers.image.revision")
end_revision = current_info.get("Labels", {}).get("org.opencontainers.image.revision")
commits: list[str] = []
if start_revision and end_revision:
try:
commits = run(
"git",
"log",
"--format=%H%x09%s%x09%an",
f"{start_revision}..{end_revision}",
).splitlines()
except subprocess.CalledProcessError as error:
# A release may be built after histories are intentionally joined
# or rewritten. In that case the previous image revision can be
# valid metadata but not a reachable Git revision in this checkout.
# Package changes and the release itself remain valid, so omit the
# commit section instead of failing the whole release job.
print(
"Warning: unable to determine commit range "
f"{start_revision}..{end_revision}: {error}"
)
lines = [args.handwritten.strip() or f"This is an automatically generated changelog for release `{args.current}`.", ""]
if previous:
lines.append(f"From previous `{args.channel}` version `{previous}` there have been the following package changes.")
else:
lines.append("No previous release was found for this channel; package changes are shown against an empty baseline.")
lines += ["", "### Package changes", "", "| Name | Previous | New |", "| --- | --- | --- |"]
if previous and previous_packages is None:
lines.append("| — | Previous release has no attached SBOM | Diff starts with the next release |")
elif not changes:
lines.append("| — | — | — |")
else:
lines += [f"| `{name}` | {markdown_version(old)} | {markdown_version(new)} |" for name, old, new in changes]
lines += ["", "### Commits", "", "| Hash | Subject | Author |", "| --- | --- | --- |"]
for commit in commits:
commit_hash, subject, author = commit.split("\t", 2)
lines.append(f"| [{commit_hash[:7]}](https://github.com/{args.repository}/commit/{commit_hash}) | {subject} | {author} |")
if not commits:
lines.append("| — | No commit range available | — |")
lines += ["", "### How to switch", "", "For current users, switch to the channel image with:", "", "```bash", f"sudo bootc switch {args.image}:{args.channel}", "", "# Or switch to this exact image:", f"sudo bootc switch {args.image}:{args.current}", "```", ""]
args.output.write_text("\n".join(lines))
if __name__ == "__main__":
main()
|