From 28c2fe18adfcb33a0c328ffff94a638c45b63592 Mon Sep 17 00:00:00 2001 From: Scott Dodson Date: Mon, 30 Mar 2026 23:02:59 -0400 Subject: [PATCH 1/5] release-controller: per-stream RPM helpers and machine-os stream discovery Add ListMachineOSStreams from release payload JSON (coreos + extensions pairing, io.openshift.build.version-display-names for titles). Extend ReleaseInfo with ImageReferenceForComponent, RpmListForStream, RpmDiffForStream, and groupcache keys. Add ReleaseTagIsDualRHCOS semver helper for 4.21+. Co-authored-by: Cursor Assisted-by: Cursor AI (Composer) --- pkg/release-controller/machine_os_tags.go | 147 ++++++++++++ .../machine_os_tags_test.go | 78 +++++++ pkg/release-controller/release_info.go | 218 ++++++++++++++++++ pkg/release-controller/semver.go | 10 + .../semver_dual_rhcos_test.go | 24 ++ 5 files changed, 477 insertions(+) create mode 100644 pkg/release-controller/machine_os_tags.go create mode 100644 pkg/release-controller/machine_os_tags_test.go create mode 100644 pkg/release-controller/semver_dual_rhcos_test.go diff --git a/pkg/release-controller/machine_os_tags.go b/pkg/release-controller/machine_os_tags.go new file mode 100644 index 000000000..6cd552901 --- /dev/null +++ b/pkg/release-controller/machine_os_tags.go @@ -0,0 +1,147 @@ +package releasecontroller + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// MachineOSStreamInfo describes one machine-OS image stream (base tag + optional display name from payload). +type MachineOSStreamInfo struct { + Tag string `json:"tag"` + DisplayName string `json:"displayName,omitempty"` +} + +// releaseInfoImageRefs is the subset of `oc adm release info -o json` needed to list payload tags. +type releaseInfoImageRefs struct { + References struct { + Spec struct { + Tags []struct { + Name string `json:"name"` + Annotations map[string]string `json:"annotations"` + } `json:"tags"` + } `json:"spec"` + } `json:"references"` +} + +const versionDisplayNamesKey = "io.openshift.build.version-display-names" + +// ListMachineOSStreams returns machine-OS base tags discovered by pairing each *coreos* extensions +// image with its base tag. Display names come from io.openshift.build.version-display-names +// (machine-os=...) on the base image, as used in current OCP payloads (e.g. 4.21 nightlies). +// Convention: extensions tag is "-extensions" (rhel-coreos-extensions, rhel-coreos-10-extensions). +func (r *ExecReleaseInfo) ListMachineOSStreams(releaseImage string) ([]MachineOSStreamInfo, error) { + raw, err := r.ReleaseInfo(releaseImage) + if err != nil { + return nil, err + } + return machineOSStreamsFromReleaseJSON(raw) +} + +// machineOSStreamsFromReleaseJSON parses release JSON for tests and shared logic. +func machineOSStreamsFromReleaseJSON(raw string) ([]MachineOSStreamInfo, error) { + var ri releaseInfoImageRefs + if err := json.Unmarshal([]byte(raw), &ri); err != nil { + return nil, err + } + + tagSet := make(map[string]struct{}, len(ri.References.Spec.Tags)) + annByTag := make(map[string]map[string]string, len(ri.References.Spec.Tags)) + for _, t := range ri.References.Spec.Tags { + if t.Name == "" { + continue + } + tagSet[t.Name] = struct{}{} + if len(t.Annotations) > 0 { + annByTag[t.Name] = t.Annotations + } + } + + var bases []string + for name := range tagSet { + if !strings.HasSuffix(name, "-extensions") { + continue + } + if !strings.Contains(name, "coreos") { + continue + } + base := strings.TrimSuffix(name, "-extensions") + if base == "" { + continue + } + if _, ok := tagSet[base]; !ok { + continue + } + bases = append(bases, base) + } + + sortMachineOSTags(bases) + out := make([]MachineOSStreamInfo, 0, len(bases)) + for _, base := range bases { + dn := "" + if a, ok := annByTag[base]; ok { + dn = machineOSDisplayNameFromAnnotations(a) + } + out = append(out, MachineOSStreamInfo{Tag: base, DisplayName: dn}) + } + return out, nil +} + +func machineOSDisplayNameFromAnnotations(annotations map[string]string) string { + v := strings.TrimSpace(annotations[versionDisplayNamesKey]) + if v == "" { + return "" + } + // Typical: "machine-os=Red Hat Enterprise Linux CoreOS" (single pair). + for _, part := range strings.Split(v, ",") { + part = strings.TrimSpace(part) + const prefix = "machine-os=" + if strings.HasPrefix(part, prefix) { + return strings.TrimSpace(strings.TrimPrefix(part, prefix)) + } + } + return "" +} + +// MachineOSTitle returns a markdown subsection title for a stream (display name + tag in backticks). +func MachineOSTitle(s MachineOSStreamInfo) string { + if s.DisplayName != "" { + return fmt.Sprintf("%s (`%s`)", s.DisplayName, s.Tag) + } + switch s.Tag { + case "rhel-coreos": + return "Red Hat Enterprise Linux CoreOS (`rhel-coreos`)" + case "rhel-coreos-10": + return "Red Hat Enterprise Linux CoreOS 10 (`rhel-coreos-10`)" + case "stream-coreos": + return "Stream CoreOS (`stream-coreos`)" + default: + return fmt.Sprintf("Machine OS (`%s`)", s.Tag) + } +} + +func sortMachineOSTags(tags []string) { + sort.SliceStable(tags, func(i, j int) bool { + return machineOSTagLess(tags[i], tags[j]) + }) +} + +func machineOSTagLess(a, b string) bool { + prio := map[string]int{ + "rhel-coreos": 0, + "stream-coreos": 1, + } + pa, okA := prio[a] + pb, okB := prio[b] + switch { + case okA && okB: + return pa < pb + case okA: + return true + case okB: + return false + default: + return a < b + } +} diff --git a/pkg/release-controller/machine_os_tags_test.go b/pkg/release-controller/machine_os_tags_test.go new file mode 100644 index 000000000..46cddfcf0 --- /dev/null +++ b/pkg/release-controller/machine_os_tags_test.go @@ -0,0 +1,78 @@ +package releasecontroller + +import ( + "reflect" + "testing" +) + +func TestMachineOSStreamsFromReleaseJSON_nightly421(t *testing.T) { + // Subset of oc adm release info -o json for registry.ci.openshift.org/ocp/release:4.21.0-0.nightly-2026-03-30-143812 + const raw = `{ + "references": { + "spec": { + "tags": [ + { + "name": "rhel-coreos", + "annotations": { + "io.openshift.build.version-display-names": "machine-os=Red Hat Enterprise Linux CoreOS", + "io.openshift.build.versions": "machine-os=9.6.20260327-0" + } + }, + { + "name": "rhel-coreos-10", + "annotations": { + "io.openshift.build.version-display-names": "machine-os=Red Hat Enterprise Linux CoreOS 10.2", + "io.openshift.build.versions": "machine-os=10.2.20260328-0" + } + }, + { + "name": "rhel-coreos-10-extensions", + "annotations": {} + }, + { + "name": "rhel-coreos-extensions", + "annotations": {} + } + ] + } + } +}` + + got, err := machineOSStreamsFromReleaseJSON(raw) + if err != nil { + t.Fatal(err) + } + want := []MachineOSStreamInfo{ + {Tag: "rhel-coreos", DisplayName: "Red Hat Enterprise Linux CoreOS"}, + {Tag: "rhel-coreos-10", DisplayName: "Red Hat Enterprise Linux CoreOS 10.2"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("machineOSStreamsFromReleaseJSON() = %#v, want %#v", got, want) + } +} + +func TestMachineOSDisplayNameFromAnnotations(t *testing.T) { + tests := []struct { + ann map[string]string + want string + }{ + {nil, ""}, + {map[string]string{versionDisplayNamesKey: "machine-os=Foo Bar"}, "Foo Bar"}, + {map[string]string{versionDisplayNamesKey: " machine-os=Foo Bar "}, "Foo Bar"}, + {map[string]string{versionDisplayNamesKey: "other=x, machine-os=CoreOS 10"}, "CoreOS 10"}, + } + for _, tt := range tests { + if got := machineOSDisplayNameFromAnnotations(tt.ann); got != tt.want { + t.Errorf("machineOSDisplayNameFromAnnotations(%v) = %q, want %q", tt.ann, got, tt.want) + } + } +} + +func TestMachineOSTitle(t *testing.T) { + if got := MachineOSTitle(MachineOSStreamInfo{Tag: "rhel-coreos", DisplayName: "Red Hat Enterprise Linux CoreOS"}); got != "Red Hat Enterprise Linux CoreOS (`rhel-coreos`)" { + t.Errorf("got %q", got) + } + if got := MachineOSTitle(MachineOSStreamInfo{Tag: "custom-stream", DisplayName: ""}); got != "Machine OS (`custom-stream`)" { + t.Errorf("got %q", got) + } +} diff --git a/pkg/release-controller/release_info.go b/pkg/release-controller/release_info.go index 04cded712..fac595f59 100644 --- a/pkg/release-controller/release_info.go +++ b/pkg/release-controller/release_info.go @@ -95,6 +95,24 @@ func NewCachingReleaseInfo(info ReleaseInfo, size int64, architecture string) Re } } } + case "rpmdiffstream": + if len(parts) < 4 { + s, err = "", fmt.Errorf("invalid rpmdiffstream key") + } else if strings.Contains(parts[1], "\x00") || strings.Contains(parts[2], "\x00") || strings.Contains(parts[3], "\x00") { + s, err = "", fmt.Errorf("invalid from/to/component") + } else { + var rpmdiff RpmDiff + rpmdiff, err = info.RpmDiffForStream(parts[1], parts[2], parts[3]) + if err == nil { + var rpmdiffByte []byte + rpmdiffByte, err = json.Marshal(rpmdiff) + if err != nil { + klog.V(4).Infof("Failed to Marshal Rpm Diff stream; from: %s to: %s img: %s; %s", parts[1], parts[2], parts[3], err) + } else { + s = string(rpmdiffByte) + } + } + } case "rpmlist": var rpmlist RpmList rpmlist, err = info.RpmList(parts[1]) @@ -107,6 +125,44 @@ func NewCachingReleaseInfo(info ReleaseInfo, size int64, architecture string) Re s = string(rpmlistByte) } } + case "rpmliststream": + if len(parts) < 4 { + s, err = "", fmt.Errorf("invalid rpmliststream key") + } else { + var rpmlist RpmList + rpmlist, err = info.RpmListForStream(parts[1], parts[2], parts[3]) + if err == nil { + var rpmlistByte []byte + rpmlistByte, err = json.Marshal(rpmlist) + if err != nil { + klog.V(4).Infof("Failed to Marshal Rpm List for stream %s/%s; %s", parts[2], parts[3], err) + } else { + s = string(rpmlistByte) + } + } + } + case "imagefor": + if len(parts) < 3 { + s, err = "", fmt.Errorf("invalid imagefor key") + } else { + s, err = info.ImageReferenceForComponent(parts[1], parts[2]) + } + case "machineosstreams": + if len(parts) < 2 { + s, err = "", fmt.Errorf("invalid machineosstreams key") + } else { + var streams []MachineOSStreamInfo + streams, err = info.ListMachineOSStreams(parts[1]) + if err == nil { + var b []byte + b, err = json.Marshal(streams) + if err != nil { + klog.V(4).Infof("Failed to Marshal machine OS streams for %s; %s", parts[1], err) + } else { + s = string(b) + } + } + } case "changelog": if strings.Contains(parts[1], "\x00") || strings.Contains(parts[2], "\x00") || strings.Contains(parts[3], "\x00") { s, err = "", fmt.Errorf("invalid from/to") @@ -162,6 +218,22 @@ func (c *CachingReleaseInfo) RpmDiff(from, to string) (RpmDiff, error) { return rpmdiff, nil } +func (c *CachingReleaseInfo) RpmDiffForStream(fromRelease, toRelease, rpmdbImageName string) (RpmDiff, error) { + var s string + err := c.cache.Get(context.TODO(), strings.Join([]string{"rpmdiffstream", fromRelease, toRelease, rpmdbImageName}, "\x00"), groupcache.StringSink(&s)) + if err != nil { + return RpmDiff{}, err + } + if s == "" { + return RpmDiff{}, nil + } + var rpmdiff RpmDiff + if err = json.Unmarshal([]byte(s), &rpmdiff); err != nil { + return RpmDiff{}, err + } + return rpmdiff, nil +} + func (c *CachingReleaseInfo) RpmList(image string) (RpmList, error) { var s string err := c.cache.Get(context.TODO(), strings.Join([]string{"rpmlist", image}, "\x00"), groupcache.StringSink(&s)) @@ -178,6 +250,47 @@ func (c *CachingReleaseInfo) RpmList(image string) (RpmList, error) { return rpmlist, nil } +func (c *CachingReleaseInfo) RpmListForStream(releaseImage, coreosTagName, extensionsTagName string) (RpmList, error) { + var s string + err := c.cache.Get(context.TODO(), strings.Join([]string{"rpmliststream", releaseImage, coreosTagName, extensionsTagName}, "\x00"), groupcache.StringSink(&s)) + if err != nil { + return RpmList{}, err + } + if s == "" { + return RpmList{}, nil + } + var rpmlist RpmList + if err = json.Unmarshal([]byte(s), &rpmlist); err != nil { + return RpmList{}, err + } + return rpmlist, nil +} + +func (c *CachingReleaseInfo) ImageReferenceForComponent(releaseImage, componentName string) (string, error) { + var s string + err := c.cache.Get(context.TODO(), strings.Join([]string{"imagefor", releaseImage, componentName}, "\x00"), groupcache.StringSink(&s)) + if err != nil { + return "", err + } + return s, nil +} + +func (c *CachingReleaseInfo) ListMachineOSStreams(releaseImage string) ([]MachineOSStreamInfo, error) { + var s string + err := c.cache.Get(context.TODO(), strings.Join([]string{"machineosstreams", releaseImage}, "\x00"), groupcache.StringSink(&s)) + if err != nil { + return nil, err + } + if s == "" { + return nil, nil + } + var streams []MachineOSStreamInfo + if err = json.Unmarshal([]byte(s), &streams); err != nil { + return nil, err + } + return streams, nil +} + func (c *CachingReleaseInfo) ChangeLog(from, to string, json bool) (string, error) { var s string err := c.cache.Get(context.TODO(), strings.Join([]string{"changelog", from, to, strconv.FormatBool(json)}, "\x00"), groupcache.StringSink(&s)) @@ -226,7 +339,14 @@ type ReleaseInfo interface { Bugs(from, to string) ([]BugDetails, error) ChangeLog(from, to string, json bool) (string, error) RpmList(image string) (RpmList, error) + RpmListForStream(releaseImage, coreosTagName, extensionsTagName string) (RpmList, error) RpmDiff(from, to string) (RpmDiff, error) + // RpmDiffForStream runs rpmdb-diff between two release payloads for a specific machine-os component (e.g. rhel-coreos, rhel-coreos-10). + RpmDiffForStream(fromRelease, toRelease, rpmdbImageName string) (RpmDiff, error) + // ImageReferenceForComponent resolves a component tag (e.g. rhel-coreos) to a pull spec via oc adm release info --image-for. + ImageReferenceForComponent(releaseImage, componentName string) (string, error) + // ListMachineOSStreams returns machine-OS streams from the release payload (see machine_os_tags.go). + ListMachineOSStreams(releaseImage string) ([]MachineOSStreamInfo, error) ReleaseInfo(image string) (string, error) UpgradeInfo(image string) (ReleaseUpgradeInfo, error) ImageInfo(image, architecture string) (string, error) @@ -467,6 +587,77 @@ func (r *ExecReleaseInfo) RpmList(image string) (RpmList, error) { return rpmlist, nil } +func (r *ExecReleaseInfo) ImageReferenceForComponent(releaseImage, componentName string) (string, error) { + if _, err := imagereference.Parse(releaseImage); err != nil { + return "", fmt.Errorf("%s is not an image reference: %v", releaseImage, err) + } + if strings.HasPrefix(releaseImage, "-") { + return "", fmt.Errorf("not a valid reference") + } + out, _, err := ocCmd("adm", "release", "info", "--image-for", componentName, releaseImage) + if err != nil { + return "", fmt.Errorf("failed to resolve image for component %q in %s: %v", componentName, releaseImage, err) + } + ref := strings.TrimSpace(string(out)) + if ref == "" { + return "", fmt.Errorf("empty image reference for component %q in %s", componentName, releaseImage) + } + return ref, nil +} + +// RpmListForStream loads the RPM database for a machine-os component inside the release payload +// (--rpmdb on the release image plus --rpmdb-image) and extensions from the named extensions tag. +func (r *ExecReleaseInfo) RpmListForStream(releaseImage, coreosTagName, extensionsTagName string) (RpmList, error) { + if _, err := imagereference.Parse(releaseImage); err != nil { + return RpmList{}, fmt.Errorf("%s is not an image reference: %v", releaseImage, err) + } + if strings.HasPrefix(releaseImage, "-") { + return RpmList{}, fmt.Errorf("not a valid reference") + } + + var rpmlist RpmList + + out, _, err := ocCmd("adm", "release", "info", "--rpmdb-cache=/tmp/rpmdb/", "--output=json", "--rpmdb", "--rpmdb-image="+coreosTagName, releaseImage) + if err != nil { + return RpmList{}, fmt.Errorf("failed to query RPM list for %s (rpmdb-image %s): %v", releaseImage, coreosTagName, err) + } + if err = json.Unmarshal(out, &rpmlist.Packages); err != nil { + return RpmList{}, fmt.Errorf("unmarshaling RPM list: %s", err) + } + + extTag := extensionsTagName + if _, ok := rpmlist.Packages["centos-stream-release"]; ok && extTag == "rhel-coreos-extensions" { + extTag = "stream-coreos-extensions" + } + + extensionsPull, err := r.ImageReferenceForComponent(releaseImage, extTag) + if err != nil { + return RpmList{}, err + } + + tmpdir, err := os.MkdirTemp("", "extensions") + if err != nil { + return RpmList{}, fmt.Errorf("failed to create tmpdir for RPM extensions list for %s", extensionsPull) + } + + defer os.RemoveAll(tmpdir) + // see https://github.com/openshift/os/commit/31816acb1ae377c9c48f1e4bc70fbf63cf4adc2d + _, _, err = ocCmdExt(tmpdir, "image", "extract", extensionsPull+"[-1]", "--file", coreosExtensionsMetadataPath) + if err != nil { + return RpmList{}, fmt.Errorf("failed to query RPM extensions list for %s", extensionsPull) + } + extensions, err := os.ReadFile(filepath.Join(tmpdir, "extensions.json")) + if err != nil { + klog.Warningf("Continuing without extensions information: %v\n", err) + return rpmlist, nil + } + if err = json.Unmarshal(extensions, &rpmlist.Extensions); err != nil { + return RpmList{}, fmt.Errorf("unmarshaling extensions: %s", err) + } + + return rpmlist, nil +} + func (r *ExecReleaseInfo) RpmDiff(from, to string) (RpmDiff, error) { if _, err := imagereference.Parse(from); err != nil { return RpmDiff{}, fmt.Errorf("%s is not an image reference: %v", from, err) @@ -491,6 +682,33 @@ func (r *ExecReleaseInfo) RpmDiff(from, to string) (RpmDiff, error) { return rpmdiff, nil } +func (r *ExecReleaseInfo) RpmDiffForStream(fromRelease, toRelease, rpmdbImageName string) (RpmDiff, error) { + if _, err := imagereference.Parse(fromRelease); err != nil { + return RpmDiff{}, fmt.Errorf("%s is not an image reference: %v", fromRelease, err) + } + if _, err := imagereference.Parse(toRelease); err != nil { + return RpmDiff{}, fmt.Errorf("%s is not an image reference: %v", toRelease, err) + } + if strings.HasPrefix(fromRelease, "-") || strings.HasPrefix(toRelease, "-") { + return RpmDiff{}, fmt.Errorf("not a valid reference") + } + if rpmdbImageName == "" { + return r.RpmDiff(fromRelease, toRelease) + } + + out, _, err := ocCmd("adm", "release", "info", "--rpmdb-cache=/tmp/rpmdb/", "--output=json", "--rpmdb-image="+rpmdbImageName, "--rpmdb-diff", fromRelease, toRelease) + if err != nil { + return RpmDiff{}, fmt.Errorf("could not generate RPM diff for %s to %s (rpmdb-image %s): %v", fromRelease, toRelease, rpmdbImageName, err) + } + + var rpmdiff RpmDiff + if err = json.Unmarshal(out, &rpmdiff); err != nil { + return RpmDiff{}, fmt.Errorf("unmarshaling RPM diff: %s", err) + } + + return rpmdiff, nil +} + type RpmList struct { Packages map[string]string `json:"packages"` Extensions map[string]string `json:"extensions"` diff --git a/pkg/release-controller/semver.go b/pkg/release-controller/semver.go index 4b50c707b..2a0a8588a 100644 --- a/pkg/release-controller/semver.go +++ b/pkg/release-controller/semver.go @@ -164,3 +164,13 @@ func SemverParseTolerant(v string) (semver.Version, error) { func SemverToMajorMinor(sr semver.Version) string { return fmt.Sprintf("%d.%d", sr.Major, sr.Minor) } + +// ReleaseTagIsDualRHCOS reports whether the target release tag should show independent +// RHCOS 9 (rhel-coreos) and RHCOS 10 (rhel-coreos-10) changelogs and node image sections. +func ReleaseTagIsDualRHCOS(toTag string) bool { + v, err := SemverParseTolerant(toTag) + if err != nil { + return false + } + return v.Major == 4 && v.Minor >= 21 +} diff --git a/pkg/release-controller/semver_dual_rhcos_test.go b/pkg/release-controller/semver_dual_rhcos_test.go new file mode 100644 index 000000000..7317885e2 --- /dev/null +++ b/pkg/release-controller/semver_dual_rhcos_test.go @@ -0,0 +1,24 @@ +package releasecontroller + +import "testing" + +func TestReleaseTagIsDualRHCOS(t *testing.T) { + tests := []struct { + tag string + want bool + }{ + {"4.21.0-ec.1", true}, + {"4.21.0", true}, + {"4.22.1", true}, + {"4.20.0", false}, + {"4.20.0-ec.0", false}, + {"not-a-version", false}, + } + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + if got := ReleaseTagIsDualRHCOS(tt.tag); got != tt.want { + t.Errorf("ReleaseTagIsDualRHCOS(%q) = %v, want %v", tt.tag, got, tt.want) + } + }) + } +} From 66eebaba4249f30063f15a4e1bf10bc0d193bf4c Mon Sep 17 00:00:00 2001 From: Scott Dodson Date: Mon, 30 Mar 2026 23:03:00 -0400 Subject: [PATCH 2/5] rhcos: multi-stream CoreOS markdown and node image section Transform RHEL 9/10 and CentOS CoreOS lines; add NodeImageSectionMarkdown using ListMachineOSStreams for generic per-stream RPM lists and diffs. Render Node Image Info when streams exist even without #node-image-info anchor. Co-authored-by: Cursor Assisted-by: Cursor AI (Composer) --- pkg/rhcos/node_image.go | 63 ++++++++++ pkg/rhcos/rhcos.go | 261 ++++++++++++++++++++++++++-------------- pkg/rhcos/rhcos_test.go | 45 +++++++ 3 files changed, 278 insertions(+), 91 deletions(-) create mode 100644 pkg/rhcos/node_image.go diff --git a/pkg/rhcos/node_image.go b/pkg/rhcos/node_image.go new file mode 100644 index 000000000..5c5cb8e64 --- /dev/null +++ b/pkg/rhcos/node_image.go @@ -0,0 +1,63 @@ +package rhcos + +import ( + "strings" + + releasecontroller "github.com/openshift/release-controller/pkg/release-controller" +) + +// NodeImageSectionMarkdown returns markdown for the Node Image Info block (package lists, RPM diffs, +// extensions, base-layer reprint). It returns ("", nil) when there is nothing to show: no +// #node-image-info anchor and no machine-os streams on the target release (see ListMachineOSStreams). +// +// Older changelogs embedded #node-image-info via the CoreOS infobox in TransformMarkDownOutput. +// Newer oc releases may omit the "* Red Hat Enterprise Linux CoreOS upgraded from …" summary lines, +// so that anchor is absent even when the payload has rhel-coreos* streams—we still render node +// info when streams are discoverable. +func NodeImageSectionMarkdown(info releasecontroller.ReleaseInfo, fromReleasePullSpec, toReleasePullSpec, changelogMarkdown string) (string, error) { + streams, err := info.ListMachineOSStreams(toReleasePullSpec) + if err != nil { + return "", err + } + + hasAnchor := strings.Contains(changelogMarkdown, "#node-image-info") + if !hasAnchor && len(streams) == 0 { + return "", nil + } + + if len(streams) == 0 { + rpmlist, err := info.RpmList(toReleasePullSpec) + if err != nil { + return "", err + } + + rpmdiff, err := info.RpmDiff(fromReleasePullSpec, toReleasePullSpec) + if err != nil { + return "", err + } + + return RenderNodeImageInfo(changelogMarkdown, rpmlist, rpmdiff), nil + } + + var nodeStreams []CoreOSNodeStream + for _, ms := range streams { + ext := ms.Tag + "-extensions" + list, err := info.RpmListForStream(toReleasePullSpec, ms.Tag, ext) + if err != nil { + return "", err + } + var diff releasecontroller.RpmDiff + if _, errFrom := info.ImageReferenceForComponent(fromReleasePullSpec, ms.Tag); errFrom == nil { + diff, err = info.RpmDiffForStream(fromReleasePullSpec, toReleasePullSpec, ms.Tag) + if err != nil { + return "", err + } + } + nodeStreams = append(nodeStreams, CoreOSNodeStream{ + Title: releasecontroller.MachineOSTitle(ms), + RpmList: list, + RpmDiff: diff, + }) + } + return RenderDualNodeImageInfo(changelogMarkdown, nodeStreams), nil +} diff --git a/pkg/rhcos/rhcos.go b/pkg/rhcos/rhcos.go index 477a6fd91..2e82ca729 100644 --- a/pkg/rhcos/rhcos.go +++ b/pkg/rhcos/rhcos.go @@ -15,8 +15,9 @@ import ( ) const ( - rhelCoreOs = "Red Hat Enterprise Linux CoreOS" - centosStreamCoreOs = "CentOS Stream CoreOS" + rhelCoreOs = "Red Hat Enterprise Linux CoreOS" + rhelCoreOs10 = "Red Hat Enterprise Linux CoreOS 10" + centosStreamCoreOs = "CentOS Stream CoreOS" baseLayerAlertBox = `

The CoreOS links above are for the base CoreOS layer used to build the OpenShift node image and do not contain OpenShift components. This is @@ -40,6 +41,10 @@ var ( reMdRHCoSDiff = regexp.MustCompile(`\* Red Hat Enterprise Linux CoreOS(?: \d+\.\d+)? upgraded from ((\d+)\.[\w\.\-]+) to ((\d+)\.[\w\.\-]+)\n`) reMdRHCoSVersion = regexp.MustCompile(`\* Red Hat Enterprise Linux CoreOS(?: \d+\.\d+)? ((\d+)\.[\w\.\-]+)\n`) + // RHEL 10 node image (rhel-coreos-10); match before generic RHCOS regex (longer prefix first). + reMdRHCoS10Diff = regexp.MustCompile(`\* Red Hat Enterprise Linux CoreOS 10(?: \d+\.\d+)? upgraded from ((\d+)\.[\w\.\-]+) to ((\d+)\.[\w\.\-]+)\n`) + reMdRHCoS10Version = regexp.MustCompile(`\* Red Hat Enterprise Linux CoreOS 10(?: \d+\.\d+)? ((\d+)\.[\w\.\-]+)\n`) + reMdCentOSCoSDiff = regexp.MustCompile(`\* CentOS Stream CoreOS upgraded from ((\d+)\.[\w\.\-]+) to ((\d+)\.[\w\.\-]+)\n`) reMdCentOSCoSVersion = regexp.MustCompile(`\* CentOS Stream CoreOS ((\d+)\.[\w\.\-]+)\n`) @@ -70,17 +75,48 @@ func TransformMarkDownOutput(markdown, fromTag, toTag, architecture, architectur // add link to tag from which current version promoted from markdown = reMdPromotedFrom.ReplaceAllString(markdown, fmt.Sprintf("Release %s was created from [$1:$2](/releasetag/$2)", toTag)) - // TODO: As we get more comfortable with these sorts of transformations, we could make them more generic. - // For now, this will have to do. - if m := reMdRHCoSDiff.FindStringSubmatch(markdown); m != nil { - markdown = transformCoreOSUpgradeLinks(rhelCoreOs, architecture, architectureExtension, markdown, m) - } else if m = reMdCentOSCoSDiff.FindStringSubmatch(markdown); m != nil { - markdown = transformCoreOSUpgradeLinks(centosStreamCoreOs, architecture, architectureExtension, markdown, m) + // Apply CoreOS link transforms for every matching line (OpenShift 4.21+ may list RHCOS 9 and 10 separately). + for { + var m []string + var name string + switch { + case reMdRHCoS10Diff.MatchString(markdown): + m = reMdRHCoS10Diff.FindStringSubmatch(markdown) + name = rhelCoreOs10 + case reMdRHCoSDiff.MatchString(markdown): + m = reMdRHCoSDiff.FindStringSubmatch(markdown) + name = rhelCoreOs + case reMdCentOSCoSDiff.MatchString(markdown): + m = reMdCentOSCoSDiff.FindStringSubmatch(markdown) + name = centosStreamCoreOs + default: + m = nil + } + if m == nil { + break + } + markdown = transformCoreOSUpgradeLinks(name, architecture, architectureExtension, markdown, m) } - if m := reMdRHCoSVersion.FindStringSubmatch(markdown); m != nil { - markdown = transformCoreOSLinks(rhelCoreOs, architecture, architectureExtension, markdown, m) - } else if m = reMdCentOSCoSVersion.FindStringSubmatch(markdown); m != nil { - markdown = transformCoreOSLinks(centosStreamCoreOs, architecture, architectureExtension, markdown, m) + for { + var m []string + var name string + switch { + case reMdRHCoS10Version.MatchString(markdown): + m = reMdRHCoS10Version.FindStringSubmatch(markdown) + name = rhelCoreOs10 + case reMdRHCoSVersion.MatchString(markdown): + m = reMdRHCoSVersion.FindStringSubmatch(markdown) + name = rhelCoreOs + case reMdCentOSCoSVersion.MatchString(markdown): + m = reMdCentOSCoSVersion.FindStringSubmatch(markdown) + name = centosStreamCoreOs + default: + m = nil + } + if m == nil { + break + } + markdown = transformCoreOSLinks(name, architecture, architectureExtension, markdown, m) } return markdown, nil } @@ -94,58 +130,8 @@ func TransformJsonOutput(output, architecture, architectureExtension string) (st for i, component := range changeLogJson.Components { switch component.Name { - case rhelCoreOs, centosStreamCoreOs: - var ok bool - var fromStream, toStream string - if len(component.Version) == 0 { - continue - } - if toStream, ok = getRHCoSReleaseStream(component.Version, architectureExtension); ok { - toURL := url.URL{ - Scheme: serviceScheme, - Host: serviceUrl, - Path: "/", - Fragment: component.Version, - RawQuery: (url.Values{ - "stream": []string{toStream}, - "arch": []string{architecture}, - "release": []string{component.Version}, - }).Encode(), - } - component.VersionUrl = toURL.String() - } - - if len(component.From) > 0 { - if fromStream, ok = getRHCoSReleaseStream(component.From, architectureExtension); ok { - fromUrl := url.URL{ - Scheme: serviceScheme, - Host: serviceUrl, - Path: "/", - Fragment: component.From, - RawQuery: (url.Values{ - "stream": []string{fromStream}, - "arch": []string{architecture}, - "release": []string{component.From}, - }).Encode(), - } - component.FromUrl = fromUrl.String() - - diffURL := url.URL{ - Scheme: serviceScheme, - Host: serviceUrl, - Path: "/diff.html", - RawQuery: (url.Values{ - "first_stream": []string{fromStream}, - "first_release": []string{component.From}, - "second_stream": []string{toStream}, - "second_release": []string{component.Version}, - "arch": []string{architecture}, - }).Encode(), - } - component.DiffUrl = diffURL.String() - } - } - changeLogJson.Components[i] = component + case rhelCoreOs, rhelCoreOs10, centosStreamCoreOs: + changeLogJson.Components[i] = enrichCoreOSComponentJSON(component, architecture, architectureExtension) } } @@ -157,6 +143,60 @@ func TransformJsonOutput(output, architecture, architectureExtension string) (st return string(updated), nil } +func enrichCoreOSComponentJSON(component releasecontroller.ChangeLogComponentInfo, architecture, architectureExtension string) releasecontroller.ChangeLogComponentInfo { + var ok bool + var fromStream, toStream string + if len(component.Version) == 0 { + return component + } + if toStream, ok = getRHCoSReleaseStream(component.Version, architectureExtension); ok { + toURL := url.URL{ + Scheme: serviceScheme, + Host: serviceUrl, + Path: "/", + Fragment: component.Version, + RawQuery: (url.Values{ + "stream": []string{toStream}, + "arch": []string{architecture}, + "release": []string{component.Version}, + }).Encode(), + } + component.VersionUrl = toURL.String() + } + + if len(component.From) > 0 { + if fromStream, ok = getRHCoSReleaseStream(component.From, architectureExtension); ok { + fromUrl := url.URL{ + Scheme: serviceScheme, + Host: serviceUrl, + Path: "/", + Fragment: component.From, + RawQuery: (url.Values{ + "stream": []string{fromStream}, + "arch": []string{architecture}, + "release": []string{component.From}, + }).Encode(), + } + component.FromUrl = fromUrl.String() + + diffURL := url.URL{ + Scheme: serviceScheme, + Host: serviceUrl, + Path: "/diff.html", + RawQuery: (url.Values{ + "first_stream": []string{fromStream}, + "first_release": []string{component.From}, + "second_stream": []string{toStream}, + "second_release": []string{component.Version}, + "arch": []string{architecture}, + }).Encode(), + } + component.DiffUrl = diffURL.String() + } + } + return component +} + func getRHCoSReleaseStream(version, architectureExtension string) (string, bool) { if strings.HasPrefix(version, "4") { if m := reOcpCoreOsVersion.FindStringSubmatch(version); m != nil { @@ -308,68 +348,107 @@ func transformCoreOSLinks(name, architecture, architectureExtension, input strin return strings.ReplaceAll(input, matches[0], replace) } +// CoreOSNodeStream holds RPM package lists and diffs for one rhel-coreos* or stream-coreos image. +type CoreOSNodeStream struct { + Title string + RpmList releasecontroller.RpmList + RpmDiff releasecontroller.RpmDiff +} + func RenderNodeImageInfo(markdown string, rpmList releasecontroller.RpmList, rpmDiff releasecontroller.RpmDiff) string { - output := new(strings.Builder) + return RenderDualNodeImageInfo(markdown, []CoreOSNodeStream{{RpmList: rpmList, RpmDiff: rpmDiff}}) +} - fmt.Fprintf(output, "### Package List\n\n") +// RenderDualNodeImageInfo renders one or more Node Image Info sections (e.g. multiple machine-OS +// streams in OpenShift 4.21+). +func RenderDualNodeImageInfo(markdown string, streams []CoreOSNodeStream) string { + if len(streams) == 0 { + return "" + } + var out strings.Builder + dual := len(streams) > 1 + for i, s := range streams { + if i > 0 { + out.WriteString("\n\n") + } + renderOneNodeStream(&out, s, dual) + } + out.WriteString(baseLayerFooter(markdown)) + return out.String() +} + +func renderOneNodeStream(out *strings.Builder, stream CoreOSNodeStream, dual bool) { + h := "###" + if stream.Title != "" { + fmt.Fprintf(out, "### %s\n\n", stream.Title) + h = "####" + } else if dual { + h = "####" + } + + fmt.Fprintf(out, "%s Package List\n\n", h) importantPkgs := []string{"cri-o", "kernel", "openshift-kubelet", "systemd"} for _, pkg := range importantPkgs { - fmt.Fprintf(output, "* %s-%s\n", pkg, rpmList.Packages[pkg]) + fmt.Fprintf(out, "* %s-%s\n", pkg, stream.RpmList.Packages[pkg]) } - fmt.Fprintf(output, "\n

Full list (%d packages)\n\n", len(rpmList.Packages)) - sortedPkgs := slices.Sorted(maps.Keys(rpmList.Packages)) + fmt.Fprintf(out, "\n
Full list (%d packages)\n\n", len(stream.RpmList.Packages)) + sortedPkgs := slices.Sorted(maps.Keys(stream.RpmList.Packages)) for _, pkg := range sortedPkgs { - fmt.Fprintf(output, "* %s-%s\n", pkg, rpmList.Packages[pkg]) + fmt.Fprintf(out, "* %s-%s\n", pkg, stream.RpmList.Packages[pkg]) } - fmt.Fprintf(output, "
\n\n") + fmt.Fprintf(out, "
\n\n") writeList := func(header string, elements []string) { - fmt.Fprintf(output, "### %s:\n\n", header) + fmt.Fprintf(out, "%s %s:\n\n", h, header) sort.Strings(elements) for _, elem := range elements { - fmt.Fprintf(output, "* %s\n", elem) + fmt.Fprintf(out, "* %s\n", elem) } - fmt.Fprint(output, "\n") + fmt.Fprint(out, "\n") } - if len(rpmDiff.Changed) > 0 { + if len(stream.RpmDiff.Changed) > 0 { elements := []string{} - for pkg, v := range rpmDiff.Changed { + for pkg, v := range stream.RpmDiff.Changed { elements = append(elements, fmt.Sprintf("%s %s → %s", pkg, v.Old, v.New)) } writeList("Changed", elements) } - if len(rpmDiff.Removed) > 0 { + if len(stream.RpmDiff.Removed) > 0 { elements := []string{} - for pkg, v := range rpmDiff.Removed { + for pkg, v := range stream.RpmDiff.Removed { elements = append(elements, fmt.Sprintf("%s %s", pkg, v)) } writeList("Removed", elements) } - if len(rpmDiff.Added) > 0 { + if len(stream.RpmDiff.Added) > 0 { elements := []string{} - for pkg, v := range rpmDiff.Added { + for pkg, v := range stream.RpmDiff.Added { elements = append(elements, fmt.Sprintf("%s %s", pkg, v)) } writeList("Added", elements) } - fmt.Fprintf(output, "\n\n### Extensions\n\n") + fmt.Fprintf(out, "\n\n%s Extensions\n\n", h) - fmt.Fprintf(output, "\n
Full list (%d packages)\n\n", len(rpmList.Extensions)) - sortedPkgs = slices.Sorted(maps.Keys(rpmList.Extensions)) + fmt.Fprintf(out, "\n
Full list (%d packages)\n\n", len(stream.RpmList.Extensions)) + sortedPkgs = slices.Sorted(maps.Keys(stream.RpmList.Extensions)) for _, pkg := range sortedPkgs { - fmt.Fprintf(output, "* %s-%s\n", pkg, rpmList.Extensions[pkg]) + fmt.Fprintf(out, "* %s-%s\n", pkg, stream.RpmList.Extensions[pkg]) } - fmt.Fprintf(output, "
\n\n") + fmt.Fprintf(out, "
\n\n") +} - // Reprint the version/diff line, with the browser links for the build itself. - // But put it last to de-emphasize it. Most people don't need to click on this. - if m := reMdCoSPost.FindStringSubmatch(markdown); m != nil { - fmt.Fprintf(output, "
%s **base layer**: %s\n\n", m[1], m[3]) +func baseLayerFooter(markdown string) string { + matches := reMdCoSPost.FindAllStringSubmatch(markdown, -1) + if len(matches) == 0 { + return "" } - - return output.String() + var b strings.Builder + for _, m := range matches { + fmt.Fprintf(&b, "
%s **base layer**: %s\n\n", m[1], m[3]) + } + return b.String() } diff --git a/pkg/rhcos/rhcos_test.go b/pkg/rhcos/rhcos_test.go index e3296ff4e..9547c1ef6 100644 --- a/pkg/rhcos/rhcos_test.go +++ b/pkg/rhcos/rhcos_test.go @@ -1,6 +1,7 @@ package rhcos import ( + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -218,3 +219,47 @@ func TestRHCoSVersionRegex(t *testing.T) { }) } } + +func TestRHCoS10DiffRegex(t *testing.T) { + input := "* Red Hat Enterprise Linux CoreOS 10 10.0 upgraded from 10.0.20260101-0 to 10.0.20260201-0\n" + m := reMdRHCoS10Diff.FindStringSubmatch(input) + if m == nil { + t.Fatal("expected match for RHEL 10 upgrade line") + } + if m[1] != "10.0.20260101-0" || m[3] != "10.0.20260201-0" { + t.Fatalf("unexpected submatches: %v", m) + } +} + +func TestTransformMarkDownOutputDualRHCOSLines(t *testing.T) { + input := `## Changes from 4.20.0 +* Red Hat Enterprise Linux CoreOS 9.8 upgraded from 9.8.20260101-0 to 9.8.20260201-0 +* Red Hat Enterprise Linux CoreOS 10 10.0 upgraded from 10.0.20260101-0 to 10.0.20260201-0 +` + out, err := TransformMarkDownOutput(input, "4.20.0", "4.21.0", "x86_64", "") + if err != nil { + t.Fatal(err) + } + if strings.Count(out, "coreos-base-alert") < 2 { + t.Fatalf("expected two CoreOS base layer infoboxes, got:\n%s", out) + } +} + +func TestTransformJsonOutputDualCoreOS(t *testing.T) { + j := `{ + "components": [ + {"name": "Red Hat Enterprise Linux CoreOS", "version": "9.8.20260201-0", "from": "9.8.20260101-0"}, + {"name": "Red Hat Enterprise Linux CoreOS 10", "version": "10.0.20260201-0", "from": "10.0.20260101-0"} + ] +}` + out, err := TransformJsonOutput(j, "x86_64", "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"versionUrl"`) { + t.Fatalf("expected versionUrl in output: %s", out) + } + if strings.Count(out, `"versionUrl"`) < 2 { + t.Fatalf("expected two versionUrl fields: %s", out) + } +} From 82fc185254d076a3fc97ec34941731ccf905e723 Mon Sep 17 00:00:00 2001 From: Scott Dodson Date: Mon, 30 Mar 2026 23:03:02 -0400 Subject: [PATCH 3/5] release-controller-api: fetch node image info when machine-os streams exist Match changelog-preview behavior: load node section when the changelog has #node-image-info or ListMachineOSStreams returns streams for the target release. Co-authored-by: Cursor Assisted-by: Cursor AI (Composer) --- cmd/release-controller-api/http_changelog.go | 45 +++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/cmd/release-controller-api/http_changelog.go b/cmd/release-controller-api/http_changelog.go index d04501b98..aa47f70ec 100644 --- a/cmd/release-controller-api/http_changelog.go +++ b/cmd/release-controller-api/http_changelog.go @@ -72,6 +72,7 @@ func (c *Controller) getChangeLog(ch chan renderResult, chNodeInfo chan renderRe return } ch <- renderResult{out: out} + return } out, err = rhcos.TransformMarkDownOutput(out, fromTag, toTag, architecture, archExtension) @@ -86,25 +87,31 @@ func (c *Controller) getChangeLog(ch chan renderResult, chNodeInfo chan renderRe return } - // Only request node image info if it'll be rendered. Use the exact - // check that renderChangeLog does to know if to consume from us. - if !strings.Contains(out, "#node-image-info") { + toImagePullspec := toImage.GenerateDigestPullSpec() + fromImagePullspec := fromImage.GenerateDigestPullSpec() + + // Request node image info when the changelog links to #node-image-info (CoreOS infobox) or when + // the target payload has discoverable machine-os streams (newer oc may omit RHCOS summary lines). + fetchNode := strings.Contains(out, "#node-image-info") + if !fetchNode { + streams, err := c.releaseInfo.ListMachineOSStreams(toImagePullspec) + if err != nil { + chNodeInfo <- renderResult{err: err} + return + } + fetchNode = len(streams) > 0 + } + if !fetchNode { chNodeInfo <- renderResult{} return } - toImagePullspec := toImage.GenerateDigestPullSpec() - rpmlist, err := c.releaseInfo.RpmList(toImagePullspec) - if err != nil { - chNodeInfo <- renderResult{err: err} - } - - rpmdiff, err := c.releaseInfo.RpmDiff(fromImage.GenerateDigestPullSpec(), toImagePullspec) + nodeMD, err := rhcos.NodeImageSectionMarkdown(c.releaseInfo, fromImagePullspec, toImagePullspec, out) if err != nil { chNodeInfo <- renderResult{err: err} + return } - - chNodeInfo <- renderResult{out: rhcos.RenderNodeImageInfo(out, rpmlist, rpmdiff)} + chNodeInfo <- renderResult{out: nodeMD} } func (c *Controller) renderChangeLog(w http.ResponseWriter, fromPull string, fromTag string, toPull string, toTag string, format string) { @@ -173,9 +180,17 @@ func (c *Controller) renderChangeLog(w http.ResponseWriter, fromPull string, fro fmt.Fprintf(w, `

%s

`, fmt.Sprintf("Unable to show full changelog: %s", render.err)) } - // only render a CoreOS diff if we need to; we can know this by - // checking if it links to the diff section we create here - if !strings.Contains(render.out, "#node-image-info") { + needsNode := strings.Contains(render.out, "#node-image-info") + if !needsNode && render.err == nil && format != "json" { + toImage, err := releasecontroller.GetImageInfo(c.releaseInfo, c.architecture, toPull) + if err == nil { + streams, err2 := c.releaseInfo.ListMachineOSStreams(toImage.GenerateDigestPullSpec()) + if err2 == nil && len(streams) > 0 { + needsNode = true + } + } + } + if !needsNode { return } From 80332825682eb63999edb64d7a951fd3829ec340 Mon Sep 17 00:00:00 2001 From: Scott Dodson Date: Mon, 30 Mar 2026 23:03:04 -0400 Subject: [PATCH 4/5] hack: add changelog-preview helper; gitignore sample output Local tool to run ChangeLog + RHCOS transforms without a cluster (requires oc). Ignore generated sample-changelog.md. Co-authored-by: Cursor Assisted-by: Cursor AI (Composer) --- .gitignore | 3 ++ hack/changelog-preview/main.go | 81 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 hack/changelog-preview/main.go diff --git a/.gitignore b/.gitignore index 459bba069..67f4fe3a2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ __pycache__/ coverage.out golangci-lint.out report.json + +# Local output from hack/changelog-preview (go run … > sample-changelog.md) +hack/changelog-preview/sample-changelog.md diff --git a/hack/changelog-preview/main.go b/hack/changelog-preview/main.go new file mode 100644 index 000000000..bedd4f864 --- /dev/null +++ b/hack/changelog-preview/main.go @@ -0,0 +1,81 @@ +// changelog-preview runs the same ChangeLog + RHCOS markdown transforms as the release-controller API +// without needing a Kubernetes cluster. Requires `oc` on PATH and registry pull access to the +// release images you pass. +// +// By default it also appends the Node Image Info section (RPM lists and diffs per CoreOS stream +// when applicable), matching the web UI. Use --skip-node-info for changelog-only output. +// +// Example: +// +// go run ./hack/changelog-preview/ \ +// --from quay.io/openshift-release-dev/ocp-release@sha256:... \ +// --to quay.io/openshift-release-dev/ocp-release@sha256:... \ +// --from-tag 4.20.0-0.nightly-2025-01-01-000000 \ +// --to-tag 4.21.0-ec.1 +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/openshift/release-controller/pkg/rhcos" + releasecontroller "github.com/openshift/release-controller/pkg/release-controller" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "sigs.k8s.io/prow/pkg/jira" +) + +func main() { + from := flag.String("from", "", "from release image pull spec (digest or tag@repo)") + to := flag.String("to", "", "to release image pull spec") + fromTag := flag.String("from-tag", "previous", "from tag name (for markdown link substitution)") + toTag := flag.String("to-tag", "current", "to tag name (for markdown link substitution)") + arch := flag.String("arch", "amd64", "release architecture (amd64, arm64, ...)") + skipNode := flag.Bool("skip-node-info", false, "omit Node Image Info (faster; no extra oc rpmdb/image-for calls)") + flag.Parse() + if *from == "" || *to == "" { + fmt.Fprintf(os.Stderr, "usage: changelog-preview --from --to [flags]\n") + os.Exit(2) + } + + var archName, archExt string + switch *arch { + case "amd64": + archName = "x86_64" + case "arm64": + archName = "aarch64" + archExt = fmt.Sprintf("-%s", archName) + default: + archName = *arch + archExt = fmt.Sprintf("-%s", archName) + } + + var nilClient kubernetes.Interface + var nilCfg *rest.Config + info := releasecontroller.NewExecReleaseInfo(nilClient, nilCfg, "", "", func() (string, error) { return "", nil }, jira.Client(nil)) + + out, err := info.ChangeLog(*from, *to, false) + if err != nil { + fmt.Fprintf(os.Stderr, "ChangeLog: %v\n", err) + os.Exit(1) + } + out, err = rhcos.TransformMarkDownOutput(out, *fromTag, *toTag, archName, archExt) + if err != nil { + fmt.Fprintf(os.Stderr, "TransformMarkDownOutput: %v\n", err) + os.Exit(1) + } + + if !*skipNode { + nodeMD, err := rhcos.NodeImageSectionMarkdown(info, *from, *to, out) + if err != nil { + fmt.Fprintf(os.Stderr, "NodeImageSectionMarkdown: %v\n", err) + os.Exit(1) + } + if nodeMD != "" { + out = out + "\n\n## Node Image Info\n\n" + nodeMD + } + } + + fmt.Print(out) +} From ae4ae495cddd66dac21784bc530011cf3f69fd8d Mon Sep 17 00:00:00 2001 From: Scott Dodson Date: Mon, 6 Apr 2026 19:26:12 -0400 Subject: [PATCH 5/5] Update pkg/release-controller/release_info.go to check for null bytes in all inputs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- pkg/release-controller/release_info.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/release-controller/release_info.go b/pkg/release-controller/release_info.go index fac595f59..184568aea 100644 --- a/pkg/release-controller/release_info.go +++ b/pkg/release-controller/release_info.go @@ -128,6 +128,8 @@ func NewCachingReleaseInfo(info ReleaseInfo, size int64, architecture string) Re case "rpmliststream": if len(parts) < 4 { s, err = "", fmt.Errorf("invalid rpmliststream key") + } else if strings.Contains(parts[1], "\x00") || strings.Contains(parts[2], "\x00") || strings.Contains(parts[3], "\x00") { + s, err = "", fmt.Errorf("invalid release/tag") } else { var rpmlist RpmList rpmlist, err = info.RpmListForStream(parts[1], parts[2], parts[3]) @@ -144,12 +146,16 @@ func NewCachingReleaseInfo(info ReleaseInfo, size int64, architecture string) Re case "imagefor": if len(parts) < 3 { s, err = "", fmt.Errorf("invalid imagefor key") + } else if strings.Contains(parts[1], "\x00") || strings.Contains(parts[2], "\x00") { + s, err = "", fmt.Errorf("invalid release/component") } else { s, err = info.ImageReferenceForComponent(parts[1], parts[2]) } case "machineosstreams": if len(parts) < 2 { s, err = "", fmt.Errorf("invalid machineosstreams key") + } else if strings.Contains(parts[1], "\x00") { + s, err = "", fmt.Errorf("invalid release") } else { var streams []MachineOSStreamInfo streams, err = info.ListMachineOSStreams(parts[1])