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/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 } 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 --fromThe 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, "\nFull list (%d packages)
\n\n", len(rpmList.Packages))
- sortedPkgs := slices.Sorted(maps.Keys(rpmList.Packages))
+ fmt.Fprintf(out, "\nFull 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, "Full list (%d packages)
\n\n", len(rpmList.Extensions))
- sortedPkgs = slices.Sorted(maps.Keys(rpmList.Extensions))
+ fmt.Fprintf(out, "\nFull 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, "
%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)
+ }
+}