Skip to content
Draft
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")
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