diff --git a/cmd/release-controller-api/http_changelog.go b/cmd/release-controller-api/http_changelog.go index b6f3d87ca..a9b88bf4b 100644 --- a/cmd/release-controller-api/http_changelog.go +++ b/cmd/release-controller-api/http_changelog.go @@ -76,7 +76,7 @@ func (c *Controller) getChangeLog(ctx context.Context, ch chan renderResult, chN return } - out, err = rhcos.TransformMarkDownOutput(out, fromTag, toTag, architecture, archExtension) + out, err = rhcos.TransformMarkDownOutput(out, fromTag, toTag, architecture, archExtension, c.releaseInfo, toImage.GenerateDigestPullSpec()) if err != nil { ch <- renderResult{err: err} return diff --git a/hack/changelog-preview/main.go b/hack/changelog-preview/main.go index be81342cb..9c57926ea 100644 --- a/hack/changelog-preview/main.go +++ b/hack/changelog-preview/main.go @@ -61,7 +61,7 @@ func main() { fmt.Fprintf(os.Stderr, "ChangeLog: %v\n", err) os.Exit(1) } - out, err = rhcos.TransformMarkDownOutput(out, *fromTag, *toTag, archName, archExt) + out, err = rhcos.TransformMarkDownOutput(out, *fromTag, *toTag, archName, archExt, info, *to) if err != nil { fmt.Fprintf(os.Stderr, "TransformMarkDownOutput: %v\n", err) os.Exit(1) diff --git a/pkg/release-controller/semver.go b/pkg/release-controller/semver.go index 2a0a8588a..73f6a0721 100644 --- a/pkg/release-controller/semver.go +++ b/pkg/release-controller/semver.go @@ -172,5 +172,21 @@ func ReleaseTagIsDualRHCOS(toTag string) bool { if err != nil { return false } - return v.Major == 4 && v.Minor >= 21 + return (v.Major == 4 && v.Minor >= 21) || v.Major >= 5 +} + +// PreferredMachineOSTag returns the machine-OS tag that should be displayed first +// in the Components section based on the OpenShift major version. +// - 4.Y releases prefer rhel-coreos (RHCOS 9) +// - 5.Y+ releases prefer rhel-coreos-10 (RHCOS 10) +// Returns empty string if the version can't be parsed. +func PreferredMachineOSTag(releaseTag string) string { + v, err := SemverParseTolerant(releaseTag) + if err != nil { + return "" + } + if v.Major >= 5 { + return "rhel-coreos-10" + } + return "rhel-coreos" } diff --git a/pkg/release-controller/semver_dual_rhcos_test.go b/pkg/release-controller/semver_dual_rhcos_test.go index 7317885e2..04af8a212 100644 --- a/pkg/release-controller/semver_dual_rhcos_test.go +++ b/pkg/release-controller/semver_dual_rhcos_test.go @@ -13,6 +13,10 @@ func TestReleaseTagIsDualRHCOS(t *testing.T) { {"4.20.0", false}, {"4.20.0-ec.0", false}, {"not-a-version", false}, + {"5.0.0", true}, + {"5.0.0-ec.1", true}, + {"5.1.0", true}, + {"5.2.3", true}, } for _, tt := range tests { t.Run(tt.tag, func(t *testing.T) { @@ -22,3 +26,33 @@ func TestReleaseTagIsDualRHCOS(t *testing.T) { }) } } + +func TestPreferredMachineOSTag(t *testing.T) { + tests := []struct { + tag string + want string + }{ + // 4.Y releases prefer rhel-coreos (RHCOS 9) + {"4.21.0-ec.1", "rhel-coreos"}, + {"4.21.0", "rhel-coreos"}, + {"4.22.1", "rhel-coreos"}, + {"4.20.0", "rhel-coreos"}, + {"4.20.0-ec.0", "rhel-coreos"}, + {"4.30.0", "rhel-coreos"}, + // 5.Y+ releases prefer rhel-coreos-10 (RHCOS 10) + {"5.0.0", "rhel-coreos-10"}, + {"5.0.0-ec.1", "rhel-coreos-10"}, + {"5.1.0", "rhel-coreos-10"}, + {"5.2.3", "rhel-coreos-10"}, + {"6.0.0", "rhel-coreos-10"}, + // Invalid versions return empty string + {"not-a-version", ""}, + } + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + if got := PreferredMachineOSTag(tt.tag); got != tt.want { + t.Errorf("PreferredMachineOSTag(%q) = %q, want %q", tt.tag, got, tt.want) + } + }) + } +} diff --git a/pkg/rhcos/rhcos.go b/pkg/rhcos/rhcos.go index 2e82ca729..915151db6 100644 --- a/pkg/rhcos/rhcos.go +++ b/pkg/rhcos/rhcos.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "net/url" + "os" "regexp" "slices" "sort" @@ -42,8 +43,9 @@ var ( 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`) + // [. ] allows either "CoreOS 10.2 ..." (period) or "CoreOS 10 10.2 ..." (space) after "10". + 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`) @@ -58,7 +60,161 @@ var ( reRhelCoreOsVersion = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)-(\d+)`) ) -func TransformMarkDownOutput(markdown, fromTag, toTag, architecture, architectureExtension string) (string, error) { +// swapRHCOSComponentIfNeeded replaces the RHCOS component shown in the ### Components section +// with the preferred version based on the OpenShift major version (4.Y prefers RHCOS 9, 5.Y+ prefers RHCOS 10). +func swapRHCOSComponentIfNeeded(markdown, toTag, architecture, architectureExtension string, releaseInfo releasecontroller.ReleaseInfo, toImage string) (string, error) { + // Determine preferred machine-OS tag based on release version + preferredTag := releasecontroller.PreferredMachineOSTag(toTag) + if preferredTag == "" { + // Can't parse version, skip swap + return markdown, nil + } + + // Query which machine-OS streams exist in the release + streams, err := releaseInfo.ListMachineOSStreams(toImage) + if err != nil || len(streams) == 0 { + // Can't determine streams, skip swap + return markdown, nil + } + + // Check if both rhel-coreos and rhel-coreos-10 exist + var hasRHCOS9, hasRHCOS10 bool + var rhcos9Info, rhcos10Info releasecontroller.MachineOSStreamInfo + for _, s := range streams { + if s.Tag == "rhel-coreos" { + hasRHCOS9 = true + rhcos9Info = s + } else if s.Tag == "rhel-coreos-10" { + hasRHCOS10 = true + rhcos10Info = s + } + } + + if !hasRHCOS9 || !hasRHCOS10 { + // Only one RHCOS version exists, no need to swap + return markdown, nil + } + + // Parse the Components section to find which RHCOS is currently shown + reComponentsSection := regexp.MustCompile(`(?s)(### Components.*?)\n\n###`) + componentsMatch := reComponentsSection.FindStringSubmatch(markdown) + if componentsMatch == nil { + // Can't find Components section + return markdown, nil + } + + componentsSection := componentsMatch[1] + + // Determine which RHCOS is currently shown and what we want to show + var currentlyShown, wantToShow string + var desiredInfo releasecontroller.MachineOSStreamInfo + + if strings.Contains(componentsSection, rhelCoreOs10) { + currentlyShown = "rhel-coreos-10" + } else if strings.Contains(componentsSection, rhelCoreOs) { + currentlyShown = "rhel-coreos" + } else { + // No RHCOS component found in Components section + return markdown, nil + } + + // Determine what we want to show + if preferredTag == "rhel-coreos-10" { + wantToShow = "rhel-coreos-10" + desiredInfo = rhcos10Info + } else { + wantToShow = "rhel-coreos" + desiredInfo = rhcos9Info + } + + if currentlyShown == wantToShow { + // Already showing the preferred version + return markdown, nil + } + + // Need to swap: fetch the version info for the desired RHCOS + releaseJSON, err := releaseInfo.ReleaseInfo(toImage) + if err != nil { + return markdown, fmt.Errorf("failed to get release info: %w", err) + } + + // Parse the release JSON to get the machine-os component version + var relInfo struct { + References struct { + Spec struct { + Tags []struct { + Name string `json:"name"` + Annotations map[string]string `json:"annotations"` + } `json:"tags"` + } `json:"spec"` + } `json:"references"` + } + + if err := json.Unmarshal([]byte(releaseJSON), &relInfo); err != nil { + return markdown, fmt.Errorf("failed to parse release JSON: %w", err) + } + + // Find the version annotation for the desired machine-OS tag + var desiredVersion string + for _, tag := range relInfo.References.Spec.Tags { + if tag.Name == wantToShow { + if versionAnnotation, ok := tag.Annotations["io.openshift.build.versions"]; ok { + // Parse the version from the annotation, format: "machine-os=X.Y.Z" + for _, part := range strings.Split(versionAnnotation, ",") { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "machine-os=") { + desiredVersion = strings.TrimPrefix(part, "machine-os=") + break + } + } + } + break + } + } + + if desiredVersion == "" { + // Couldn't find version, skip swap + return markdown, nil + } + + // Build the replacement RHCOS component line with proper formatting + displayName := desiredInfo.DisplayName + if displayName == "" { + displayName = releasecontroller.MachineOSTitle(desiredInfo) + } + + // Build the URL to the RHCOS release browser + stream, ok := getRHCoSReleaseStream(desiredVersion, architectureExtension) + if !ok { + // Can't determine stream, skip enrichment + return markdown, nil + } + + rhcosURL := url.URL{ + Scheme: serviceScheme, + Host: serviceUrl, + Path: "/", + Fragment: desiredVersion, + RawQuery: (url.Values{ + "stream": []string{stream}, + "arch": []string{architecture}, + "release": []string{desiredVersion}, + }).Encode(), + } + + // Create the new component line with enriched link and alert box + enrichedComponent := fmt.Sprintf("* %s [%s](%s) %s", displayName, desiredVersion, rhcosURL.String(), baseLayerAlertBox) + + // Find and replace the old RHCOS component line in the Components section + reComponentLine := regexp.MustCompile(`\* Red Hat Enterprise Linux CoreOS[^\n]+`) + + newComponentsSection := reComponentLine.ReplaceAllString(componentsSection, enrichedComponent) + markdown = strings.Replace(markdown, componentsSection, newComponentsSection, 1) + + return markdown, nil +} + +func TransformMarkDownOutput(markdown, fromTag, toTag, architecture, architectureExtension string, releaseInfo releasecontroller.ReleaseInfo, toImage string) (string, error) { // replace references to the previous version with links rePrevious, err := regexp.Compile(fmt.Sprintf(`([^\w:])%s(\W)`, regexp.QuoteMeta(fromTag))) if err != nil { @@ -75,6 +231,15 @@ 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)) + // Swap RHCOS component in Components section if needed (4.Y prefers RHCOS 9, 5.Y+ prefers RHCOS 10) + if releaseInfo != nil && toImage != "" { + markdown, err = swapRHCOSComponentIfNeeded(markdown, toTag, architecture, architectureExtension, releaseInfo, toImage) + if err != nil { + // Log but don't fail - this is a best-effort improvement + fmt.Fprintf(os.Stderr, "Warning: failed to swap RHCOS component: %v\n", err) + } + } + // Apply CoreOS link transforms for every matching line (OpenShift 4.21+ may list RHCOS 9 and 10 separately). for { var m []string @@ -85,7 +250,11 @@ func TransformMarkDownOutput(markdown, fromTag, toTag, architecture, architectur name = rhelCoreOs10 case reMdRHCoSDiff.MatchString(markdown): m = reMdRHCoSDiff.FindStringSubmatch(markdown) - name = rhelCoreOs + if fromMajor, err := strconv.Atoi(m[2]); err == nil && fromMajor >= 10 && fromMajor < 100 { + name = rhelCoreOs10 + } else { + name = rhelCoreOs + } case reMdCentOSCoSDiff.MatchString(markdown): m = reMdCentOSCoSDiff.FindStringSubmatch(markdown) name = centosStreamCoreOs @@ -106,7 +275,11 @@ func TransformMarkDownOutput(markdown, fromTag, toTag, architecture, architectur name = rhelCoreOs10 case reMdRHCoSVersion.MatchString(markdown): m = reMdRHCoSVersion.FindStringSubmatch(markdown) - name = rhelCoreOs + if vMajor, err := strconv.Atoi(m[2]); err == nil && vMajor >= 10 && vMajor < 100 { + name = rhelCoreOs10 + } else { + name = rhelCoreOs + } case reMdCentOSCoSVersion.MatchString(markdown): m = reMdCentOSCoSVersion.FindStringSubmatch(markdown) name = centosStreamCoreOs @@ -129,8 +302,10 @@ func TransformJsonOutput(output, architecture, architectureExtension string) (st } for i, component := range changeLogJson.Components { - switch component.Name { - case rhelCoreOs, rhelCoreOs10, centosStreamCoreOs: + switch { + case strings.HasPrefix(component.Name, rhelCoreOs10): + changeLogJson.Components[i] = enrichCoreOSComponentJSON(component, architecture, architectureExtension) + case component.Name == rhelCoreOs || component.Name == centosStreamCoreOs: changeLogJson.Components[i] = enrichCoreOSComponentJSON(component, architecture, architectureExtension) } } diff --git a/pkg/rhcos/rhcos_test.go b/pkg/rhcos/rhcos_test.go index 9547c1ef6..b6e43b8c6 100644 --- a/pkg/rhcos/rhcos_test.go +++ b/pkg/rhcos/rhcos_test.go @@ -221,13 +221,66 @@ 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") + testCases := []struct { + name string + input string + shouldMatch bool + fromVersion string + toVersion string + }{ + { + name: "Space-separated RHEL version (original format)", + input: "* Red Hat Enterprise Linux CoreOS 10 10.0 upgraded from 10.0.20260101-0 to 10.0.20260201-0\n", + shouldMatch: true, + fromVersion: "10.0.20260101-0", + toVersion: "10.0.20260201-0", + }, + { + name: "Period-separated RHEL minor (CoreOS 10.2)", + input: "* Red Hat Enterprise Linux CoreOS 10.2 upgraded from 10.2.20260328-0 to 10.2.20260321-0\n", + shouldMatch: true, + fromVersion: "10.2.20260328-0", + toVersion: "10.2.20260321-0", + }, + { + name: "No RHEL minor version", + input: "* Red Hat Enterprise Linux CoreOS 10 upgraded from 10.2.20260328-0 to 10.2.20260321-0\n", + shouldMatch: true, + fromVersion: "10.2.20260328-0", + toVersion: "10.2.20260321-0", + }, + { + name: "Two-digit RHEL minor", + input: "* Red Hat Enterprise Linux CoreOS 10.20 upgraded from 10.20.20270101-0 to 10.20.20270201-0\n", + shouldMatch: true, + fromVersion: "10.20.20270101-0", + toVersion: "10.20.20270201-0", + }, + { + name: "RHCOS 9 line should NOT match RHCOS 10 regex", + input: "* Red Hat Enterprise Linux CoreOS 9.8 upgraded from 9.8.20260305-0 to 9.8.20260312-0\n", + shouldMatch: false, + }, } - if m[1] != "10.0.20260101-0" || m[3] != "10.0.20260201-0" { - t.Fatalf("unexpected submatches: %v", m) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + m := reMdRHCoS10Diff.FindStringSubmatch(tc.input) + if tc.shouldMatch { + if m == nil { + t.Fatalf("expected match but got none for input: %s", tc.input) + } + if m[1] != tc.fromVersion { + t.Errorf("Expected from version %q, got %q", tc.fromVersion, m[1]) + } + if m[3] != tc.toVersion { + t.Errorf("Expected to version %q, got %q", tc.toVersion, m[3]) + } + } else { + if m != nil { + t.Errorf("Expected no match but got: %v", m) + } + } + }) } } @@ -236,7 +289,7 @@ func TestTransformMarkDownOutputDualRHCOSLines(t *testing.T) { * 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", "") + out, err := TransformMarkDownOutput(input, "4.20.0", "4.21.0", "x86_64", "", nil, "") if err != nil { t.Fatal(err) } @@ -263,3 +316,83 @@ func TestTransformJsonOutputDualCoreOS(t *testing.T) { t.Fatalf("expected two versionUrl fields: %s", out) } } + +func TestTransformJsonOutputRHCOS10WithMinor(t *testing.T) { + j := `{ + "components": [ + {"name": "Red Hat Enterprise Linux CoreOS 10.2", "version": "10.2.20260328-0", "from": "10.2.20260321-0"} + ] +}` + out, err := TransformJsonOutput(j, "x86_64", "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"versionUrl"`) { + t.Fatalf("expected versionUrl in output for RHCOS 10.2 component name: %s", out) + } + if !strings.Contains(out, "rhel-10.2") { + t.Fatalf("expected rhel-10.2 stream in URL: %s", out) + } +} + +func TestTransformMarkDownOutputRHCOS10Fallback(t *testing.T) { + input := `## Changes from 5.0.0-0.nightly-2026-03-01-000000 +* Red Hat Enterprise Linux CoreOS upgraded from 10.2.20260301-0 to 10.2.20260315-0 +` + out, err := TransformMarkDownOutput(input, "5.0.0-0.nightly-2026-03-01-000000", "5.0.0-0.nightly-2026-03-15-000000", "x86_64", "", nil, "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Red Hat Enterprise Linux CoreOS 10") { + t.Fatalf("expected RHCOS 10 label when version starts with 10.x, got:\n%s", out) + } +} + +func TestTransformMarkDownOutputRHCOS10PeriodFormat(t *testing.T) { + input := `## Changes from 5.0.0 +* Red Hat Enterprise Linux CoreOS 10.2 upgraded from 10.2.20260328-0 to 10.2.20260321-0 +` + out, err := TransformMarkDownOutput(input, "5.0.0", "5.0.1", "x86_64", "", nil, "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Red Hat Enterprise Linux CoreOS 10") { + t.Fatalf("expected RHCOS 10 label for CoreOS 10.2 format, got:\n%s", out) + } + if !strings.Contains(out, "rhel-10.2") { + t.Fatalf("expected rhel-10.2 stream in URL, got:\n%s", out) + } +} + +func TestGetRHCoSReleaseStreamRHCOS10(t *testing.T) { + testCases := []struct { + name string + version string + ok bool + expected string + }{ + { + name: "RHCOS 10.2", + version: "10.2.20260328-0", + ok: true, + expected: "prod/streams/rhel-10.2", + }, + { + name: "RHCOS 10.0", + version: "10.0.20260101-0", + ok: true, + expected: "prod/streams/rhel-10.0", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, ok := getRHCoSReleaseStream(tc.version, "") + if ok != tc.ok { + t.Errorf("expected ok=%v, got %v", tc.ok, ok) + } + if result != tc.expected { + t.Errorf("expected %q, got %q", tc.expected, result) + } + }) + } +}