summaryrefslogtreecommitdiff
path: root/.github
diff options
context:
space:
mode:
authortranch <tranch.xiao@gmail.com>2026-09-11 13:46:58 +0800
committertranch <tranch.xiao@gmail.com>2026-09-11 13:46:58 +0800
commit621e87de23348a337d45643b3682b7b3cb00000c (patch)
tree6e492ea233a8d9f098bddfe6e190240cb23bec89 /.github
parentc18aff5d552f359386736aea68a0ac9541cc5820 (diff)
downloadAeroCore-OS-621e87de23348a337d45643b3682b7b3cb00000c.tar.gz
AeroCore-OS-621e87de23348a337d45643b3682b7b3cb00000c.zip
Generate release package changelogstesting-44.20260911.1
Attach RPM SBOMs to published image digests and build release notes by comparing the current and previous SBOM artifacts.
Diffstat (limited to '.github')
-rw-r--r--.github/scripts/generate_changelog.py143
-rw-r--r--.github/workflows/build.yml45
-rw-r--r--.github/workflows/generate_release.yml35
3 files changed, 220 insertions, 3 deletions
diff --git a/.github/scripts/generate_changelog.py b/.github/scripts/generate_changelog.py
new file mode 100644
index 0000000..57f7ba9
--- /dev/null
+++ b/.github/scripts/generate_changelog.py
@@ -0,0 +1,143 @@
+#!/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 = (
+ run("git", "log", "--format=%H%x09%s%x09%an", f"{start_revision}..{end_revision}").splitlines()
+ if start_revision and end_revision
+ else []
+ )
+
+ 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()
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e9a4a95..0b6b8df 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -215,6 +215,30 @@ jobs:
"${DEFAULT_TAG}" \
"${ALIAS_TAGS}"
+ - name: Install Syft
+ id: setup-syft
+ if: github.event_name != 'pull_request'
+ uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
+ with:
+ syft-version: v1.44.0
+
+ - name: Generate RPM SBOM
+ id: generate-sbom
+ if: github.event_name != 'pull_request'
+ env:
+ SYFT_CMD: ${{ steps.setup-syft.outputs.cmd }}
+ run: |
+ set -euo pipefail
+ sbom="${RUNNER_TEMP}/aerocore-sbom.json"
+ mountpoint="$(sudo podman image mount "${IMAGE_NAME}:${DEFAULT_TAG}")"
+ trap 'sudo podman image unmount "${IMAGE_NAME}:${DEFAULT_TAG}"' EXIT
+ sudo "${SYFT_CMD}" "dir:${mountpoint}" \
+ --select-catalogers rpm \
+ --source-name "${IMAGE_NAME}-${VERSION_TAG}" \
+ -o "syft-json=${sbom}"
+ sudo chown "$(id -u):$(id -g)" "${sbom}"
+ echo "sbom=${sbom}" >> "$GITHUB_OUTPUT"
+
# These `if` statements keep pull requests from publishing packages.
# They publish default-branch builds and manual testing branch builds.
- name: Login to GitHub Container Registry
@@ -241,6 +265,26 @@ jobs:
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
+ - name: Install ORAS
+ if: github.event_name != 'pull_request'
+ uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1
+
+ - name: Upload RPM SBOM
+ if: github.event_name != 'pull_request'
+ env:
+ DIGEST: ${{ steps.push-image.outputs.digest }}
+ GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SBOM: ${{ steps.generate-sbom.outputs.sbom }}
+ run: |
+ set -euo pipefail
+ echo "${GHCR_TOKEN}" | oras login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
+ cd "$(dirname "${SBOM}")"
+ oras attach \
+ --artifact-type application/vnd.spdx+json \
+ --annotation "filename=$(basename "${SBOM}")" \
+ "${IMAGE_REGISTRY}/${IMAGE_NAME}@${DIGEST}" \
+ "$(basename "${SBOM}")"
+
# This section is optional and only needs to be enabled if you plan on distributing
# your project for others to consume. You will need to create a public and private key
# using Cosign and save the private key as a repository secret in Github for this workflow
@@ -272,6 +316,7 @@ jobs:
github.ref_name == 'testing-candidate')
permissions:
contents: write
+ packages: read
secrets: inherit
uses: ./.github/workflows/generate_release.yml
with:
diff --git a/.github/workflows/generate_release.yml b/.github/workflows/generate_release.yml
index 8ea1f54..3cc432d 100644
--- a/.github/workflows/generate_release.yml
+++ b/.github/workflows/generate_release.yml
@@ -26,12 +26,28 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: write
+ packages: read
steps:
- uses: actions/checkout@v7.0.1
with:
- fetch-depth: 50
+ fetch-depth: 0
+
+ - name: Install ORAS
+ uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Log in to GHCR with ORAS
+ env:
+ GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: echo "${GHCR_TOKEN}" | oras login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
- name: Align release tag with build commit
env:
@@ -50,12 +66,25 @@ jobs:
[[ "${target}" == "main" ]] && target="stable"
echo "target=${target}" >> "$GITHUB_OUTPUT"
+ - name: Generate package and commit changelog
+ env:
+ IMAGE: ghcr.io/${{ github.repository }}
+ RELEASE_TAG: ${{ inputs.release_tag }}
+ HANDWRITTEN: ${{ inputs.handwritten }}
+ run: |
+ python3 .github/scripts/generate_changelog.py \
+ --image "${IMAGE,,}" \
+ --repository "${GITHUB_REPOSITORY}" \
+ --current "${RELEASE_TAG}" \
+ --channel "${{ steps.target.outputs.target }}" \
+ --handwritten "${HANDWRITTEN}" \
+ --output release-notes.md
+
- name: Generate release
uses: softprops/action-gh-release@v3.0.2
with:
tag_name: "${{ inputs.release_tag }}"
name: "${{ inputs.release_tag }}"
- body: |
- ${{ inputs.handwritten }}
+ body_path: release-notes.md
prerelease: ${{ steps.target.outputs.target != 'stable' }}
make_latest: ${{ steps.target.outputs.target == 'stable' }}