diff options
| -rw-r--r-- | .github/scripts/generate_changelog.py | 143 | ||||
| -rw-r--r-- | .github/workflows/build.yml | 45 | ||||
| -rw-r--r-- | .github/workflows/generate_release.yml | 35 |
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' }} |
