Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 30 additions & 15 deletions cmd/release-controller-api/http_changelog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -173,9 +180,17 @@ func (c *Controller) renderChangeLog(w http.ResponseWriter, fromPull string, fro
fmt.Fprintf(w, `<p class="alert alert-danger">%s</p>`, 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
}

Expand Down
81 changes: 81 additions & 0 deletions hack/changelog-preview/main.go
Original file line number Diff line number Diff line change
@@ -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")
Comment on lines +29 to +31
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Typo in help text.

Line 30 says "digest or tag@repo" but the standard format is repository:tag or repository@digest (sha256:...). The help text should reflect this.

📝 Suggested fix
-	from := flag.String("from", "", "from release image pull spec (digest or tag@repo)")
+	from := flag.String("from", "", "from release image pull spec (repo:tag or repo@sha256:...)")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func main() {
from := flag.String("from", "", "from release image pull spec (digest or tag@repo)")
to := flag.String("to", "", "to release image pull spec")
func main() {
from := flag.String("from", "", "from release image pull spec (repo:tag or repo@sha256:...)")
to := flag.String("to", "", "to release image pull spec")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hack/changelog-preview/main.go` around lines 29 - 31, The help text for the
flag variables defined in main uses an incorrect format ("digest or tag@repo");
update the flag descriptions created via flag.String for the variables from and
to so they reflect the standard image pull spec forms (e.g., "repository:tag or
repository@digest (sha256:...)" or similar), making sure the strings passed to
flag.String for from and to are corrected to the proper wording.

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 <pullspec> --to <pullspec> [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)
}
147 changes: 147 additions & 0 deletions pkg/release-controller/machine_os_tags.go
Original file line number Diff line number Diff line change
@@ -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 "<base>-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
}
}
78 changes: 78 additions & 0 deletions pkg/release-controller/machine_os_tags_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading