Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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