diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dcb9175f..7cf3843b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -82,7 +82,7 @@ "name": "microshift-release", "source": "./plugins/microshift-release", "description": "Set of tools to perform MicroShift Release Testing activities", - "version": "1.4.3" + "version": "1.5.0" }, { "name": "pr-review", diff --git a/plugins/microshift-release/.claude-plugin/plugin.json b/plugins/microshift-release/.claude-plugin/plugin.json index 96efd56f..ad354ce2 100644 --- a/plugins/microshift-release/.claude-plugin/plugin.json +++ b/plugins/microshift-release/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "microshift-release", "description": "Set of tools to perform MicroShift Release Testing activities", - "version": "1.4.3", + "version": "1.5.0", "author": { "name": "agullon" }, diff --git a/plugins/microshift-release/scripts/errata_promotion.py b/plugins/microshift-release/scripts/errata_promotion.py new file mode 100644 index 00000000..86440857 --- /dev/null +++ b/plugins/microshift-release/scripts/errata_promotion.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +"""Validate Errata Tool RPM advisory promotion readiness — Phase 3. + +QE sign-off checks for MicroShift RPM advisories (Errata Tool) before +shipping. Requires VPN and a valid Kerberos ticket (kinit). + +Usage: errata_promotion.py [--verbose] [--json] +""" + +import argparse +import json +import logging +import sys +from collections import Counter + +from lib import artifacts, errata +from validate_artifacts import ( + classify_version, _pass, _fail, _warn, _skip, + _STATUS_EMOJI as _BASE_STATUS_EMOJI, +) + +_STATUS_EMOJI = {**_BASE_STATUS_EMOJI, "SKIP": "⏭️"} + +logging.basicConfig( + level=logging.INFO, + format="%(levelname)s: %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger(__name__) + +_CHECKS = [ + "et_advisory_exists", + "et_advisory_type", + "et_qa_owner", + "et_bugs_verified", + "et_rpms_present", + "et_rpms_product_listed", + "et_cdn_staging", + "et_cat_tests", + "et_status_rel_prep", +] + +_QA_DEFAULT_OWNERS = {"default", "default qa", ""} + + +def _expected_types(version_info): + if version_info["type"] == "XY": + return ["RHEA"] + return ["RHBA", "RHSA"] + + +# ── Checks ────────────────────────────────────────────────────── + + +def check_advisory_exists(advisory): + """Advisory is fetchable from the Errata Tool.""" + check_id = "et_advisory_exists" + if advisory is None: + return _fail(check_id, "Advisory not found in Errata Tool") + name = advisory.get("fulladvisory") or advisory.get("advisory_name") or "?" + return _pass(check_id, f"Advisory found: {name}", + [f"ID: {advisory.get('id', '?')}"]) + + +def check_advisory_type(advisory, version_info): + """Advisory type matches expectations for this release.""" + check_id = "et_advisory_type" + if advisory is None: + return _warn(check_id, "Advisory data unavailable") + + errata_type = (advisory.get("errata_type") + or advisory.get("type", "")).upper() + if not errata_type: + return _fail(check_id, "No errata_type in advisory response") + + expected = _expected_types(version_info) + if errata_type in expected: + return _pass(check_id, f"{errata_type} (expected for {version_info['type']})", + [f"Expected: {' or '.join(expected)}"]) + return _fail(check_id, + f"{errata_type}, expected {' or '.join(expected)} for {version_info['type']}", + [f"Got: {errata_type}", f"Expected: {' or '.join(expected)}"]) + + +def check_qa_owner(advisory): + """QA ownership has been changed from the default.""" + check_id = "et_qa_owner" + if advisory is None: + return _warn(check_id, "Advisory data unavailable") + + qa_name = (advisory.get("quality_responsibility_name") + or advisory.get("qe_group") or "") + if qa_name: + if qa_name.lower().strip() in _QA_DEFAULT_OWNERS: + return _fail(check_id, f"QA owner is still default: {qa_name!r}", + ["Change QA ownership before proceeding"]) + return _pass(check_id, f"QA: {qa_name}") + + qa_id = advisory.get("quality_responsibility_id") + if qa_id is not None and qa_id > 0: + return _pass(check_id, f"QA responsibility set (ID: {qa_id})") + return _fail(check_id, "QA ownership not set", + ["Change QA ownership before proceeding"]) + + +def check_bugs_verified(bugs): + """All OCPBUGS linked to the advisory are in an accepted state.""" + check_id = "et_bugs_verified" + if bugs is None: + return _warn(check_id, "Could not fetch Jira issues from advisory") + + if not bugs: + return _pass(check_id, "No bugs linked to advisory") + + accepted = {"verified", "closed", "release pending"} + not_verified = [b for b in bugs + if b.get("status", "").lower() not in accepted] + total = len(bugs) + verified = total - len(not_verified) + + if not not_verified: + return _pass(check_id, f"{verified}/{total} bugs in accepted state", + [b["key"] for b in bugs]) + + details = [f"{b['key']}: {b.get('status', '?')}" for b in not_verified] + return _fail(check_id, + f"{verified}/{total} verified — {len(not_verified)} not yet verified", + details) + + +def check_rpms_present(nvrs, version_info): + """MicroShift RPMs are attached to the advisory.""" + check_id = "et_rpms_present" + if not nvrs: + return _fail(check_id, "No MicroShift RPMs found in advisory builds") + + packages = errata.extract_package_names(nvrs) + expected = artifacts.get_expected_packages(version_info["minor"]) + + if expected is None: + return _warn(check_id, + f"{len(nvrs)} MicroShift NVR(s) found, " + f"but could not determine expected package list", + [f"Packages: {', '.join(sorted(packages))}", + f"NVRs: {', '.join(nvrs[:5])}"]) + + expected_set = set(expected) + missing = sorted(expected_set - packages) + if not missing: + return _pass(check_id, + f"{len(expected_set)}/{len(expected_set)} MicroShift RPMs in advisory", + [f"Packages: {', '.join(sorted(packages))}"]) + return _fail(check_id, + f"{len(missing)} MicroShift RPM(s) missing from advisory", + [f"Missing: {', '.join(missing)}", + f"Found: {', '.join(sorted(packages))}", + "Notify #forum-ocp-release / rel-eng if packages are missing"]) + + +def check_rpms_product_listed(builds_data, nvrs): + """All MicroShift NVRs are mapped to product version listings.""" + check_id = "et_rpms_product_listed" + if not builds_data: + return _warn(check_id, "Builds data unavailable") + if not nvrs: + return _warn(check_id, "No MicroShift NVRs to check") + + product_versions = [] + for pv, pv_data in builds_data.items(): + entries = pv_data + if isinstance(pv_data, dict): + entries = pv_data.get("builds", []) + if not isinstance(entries, list): + continue + for entry in entries: + if isinstance(entry, dict): + for nvr_key in entry: + if "microshift" in nvr_key.lower(): + product_versions.append(pv) + break + + if product_versions: + pv_set = sorted(set(product_versions)) + return _pass(check_id, + f"RPMs listed in {len(pv_set)} product version(s)", + pv_set) + return _fail(check_id, + "MicroShift RPMs not mapped to any product version listing", + ["Notify #forum-ocp-release / rel-eng"]) + + +def check_cdn_staging(advisory): + """CDN staging push has been completed.""" + check_id = "et_cdn_staging" + if advisory is None: + return _warn(check_id, "Advisory data unavailable") + + status = (advisory.get("status") or "").upper() + text_only = advisory.get("text_only", False) + + if text_only: + return _skip(check_id, "Text-only advisory — no CDN push needed") + + content_types = advisory.get("content_types", []) + push_count = advisory.get("pushcount") or advisory.get("push_count") + + if status in ("REL_PREP", "PUSH_READY", "IN_PUSH", "SHIPPED_LIVE"): + return _pass(check_id, f"CDN staging push completed (status: {status})", + [f"Content types: {', '.join(content_types)}"] + if content_types else []) + + if push_count is not None and push_count > 0: + return _pass(check_id, f"CDN push recorded ({push_count} push(es))") + + if status == "QE": + return _warn(check_id, + "Advisory in QE — verify CDN staging push in Errata Tool UI", + ["Check 'CDN Repos' tab in the advisory"]) + return _warn(check_id, + f"Cannot determine CDN staging status (status: {status})", + ["Check 'CDN Repos' tab in the advisory"]) + + +def check_cat_tests(advisory): + """RHN QA / CAT testing is complete.""" + check_id = "et_cat_tests" + if advisory is None: + return _warn(check_id, "Advisory data unavailable") + + rhnqa = advisory.get("rhnqa") + qa_complete = advisory.get("qa_complete") + status = (advisory.get("status") or "").upper() + + if rhnqa == 1: + return _pass(check_id, "RHN QA testing passed (rhnqa=1)") + if qa_complete == 1: + return _pass(check_id, "QA marked complete (qa_complete=1)") + if status in ("REL_PREP", "PUSH_READY", "IN_PUSH", "SHIPPED_LIVE"): + return _pass(check_id, f"QA passed (advisory status: {status})") + if rhnqa == 0 and status == "QE": + return _fail(check_id, "RHN QA testing not yet passed (rhnqa=0)", + ["Complete testing and mark RHN QA in the advisory"]) + return _warn(check_id, + f"Cannot determine QA test status (rhnqa={rhnqa}, status={status})", + ["Check 'Testing' tab in the Errata Tool UI"]) + + +def check_status_rel_prep(advisory): + """Advisory has been moved to REL_PREP status.""" + check_id = "et_status_rel_prep" + if advisory is None: + return _warn(check_id, "Advisory data unavailable") + + status = (advisory.get("status") or "").upper() + if not status: + return _fail(check_id, "No status field in advisory response") + + past_rel_prep = ("REL_PREP", "PUSH_READY", "IN_PUSH", "SHIPPED_LIVE") + if status in past_rel_prep: + return _pass(check_id, f"Status: {status}") + + if status == "QE": + return _fail(check_id, f"Status: {status} — not yet moved to REL_PREP", + ["Move advisory to REL_PREP after all checks pass"]) + return _fail(check_id, f"Status: {status} — expected REL_PREP or later", + [f"Current: {status}", + f"Expected: {' / '.join(past_rel_prep)}"]) + + +# ── Orchestrator ───────────────────────────────────────────────── + + +def run_errata_promotion_checks(version_info, advisory_id): + """Run all Errata Tool promotion checks and return results.""" + logger.info("Authenticating with Errata Tool...") + if not errata.check_auth(): + return [_fail(c, "Kerberos auth failed — run 'kinit' first") for c in _CHECKS] + + logger.info("Fetching advisory %s...", advisory_id) + advisory = errata.fetch_advisory(advisory_id) + + if advisory is None: + return [check_advisory_exists(None)] + [ + _skip(c, "Advisory not found") for c in _CHECKS[1:] + ] + + logger.info("Fetching builds...") + builds_data = errata.fetch_builds(advisory_id) + + nvrs = errata.extract_microshift_nvrs(builds_data) + + embedded_issues = advisory.get("_jira_issues") + bugs = errata.extract_bug_keys(embedded_issues) if embedded_issues is not None else None + + logger.info("Running checks...") + results = [ + check_advisory_exists(advisory), + check_advisory_type(advisory, version_info), + check_qa_owner(advisory), + check_bugs_verified(bugs), + check_rpms_present(nvrs, version_info), + check_rpms_product_listed(builds_data, nvrs), + check_cdn_staging(advisory), + check_cat_tests(advisory), + check_status_rel_prep(advisory), + ] + + return results + + +# ── Formatting ─────────────────────────────────────────────────── + + +def _section_line(title): + return f"── {title} " + "─" * max(1, 60 - len(title) - 4) + + +_CHECK_SECTIONS = { + "et_advisory_exists": "Advisory", + "et_advisory_type": "Advisory", + "et_qa_owner": "Advisory", + "et_status_rel_prep": "Advisory", + "et_bugs_verified": "Bugs", + "et_rpms_present": "Builds", + "et_rpms_product_listed": "Builds", + "et_cdn_staging": "Distribution", + "et_cat_tests": "Distribution", +} + +_SECTION_ORDER = ["Advisory", "Bugs", "Builds", "Distribution"] + + +def format_text_short(version, advisory_id, results): + """Format checks grouped by section.""" + max_id_len = max((len(r["check"]) for r in results), default=20) + + _ICON_DISPLAY_WIDTH = 2 + + def _fmt_line(r): + icon = _STATUS_EMOJI.get(r["status"], r["status"]) + cid = r["check"].ljust(max_id_len) + lines = [f"{icon} {cid} {r['reason']}"] + if r["status"] == "FAIL" and r.get("details"): + pad = " " * (_ICON_DISPLAY_WIDTH + 2 + max_id_len + 2) + for d in r["details"]: + lines.append(f"{pad}{d}") + return lines + + by_section = {} + for r in results: + section = _CHECK_SECTIONS.get(r["check"], "Other") + by_section.setdefault(section, []).append(r) + + output = [f"Errata Tool Promotion: {version} ({advisory_id})", ""] + for section in _SECTION_ORDER: + section_results = by_section.get(section, []) + if not section_results: + continue + output.append(_section_line(section)) + for r in section_results: + output.extend(_fmt_line(r)) + output.append("") + + return "\n".join(output) + + +def format_text_full(version, advisory_id, results, version_info): + """Format a detailed markdown report.""" + lines = [f"# Errata Tool Promotion: {version} ({version_info['type']})", ""] + lines.append(f"**Advisory:** {advisory_id}") + lines.append("") + + by_section = {} + for r in results: + section = _CHECK_SECTIONS.get(r["check"], "Other") + by_section.setdefault(section, []).append(r) + + for section in _SECTION_ORDER: + section_results = by_section.get(section, []) + if not section_results: + continue + lines += [ + f"## {section}", "", + "| Status | Check | Details |", + "|--------|-------|---------|", + ] + for r in section_results: + detail = "; ".join(r.get("details", [])) or r["reason"] + icon = _STATUS_EMOJI.get(r["status"], r["status"]) + lines.append(f"| {icon} | `{r['check']}` | {detail} |") + lines.append("") + + counts = Counter(r["status"] for r in results) + summary_parts = [f"{v} {k}" for k, v in sorted(counts.items())] + lines.append(f"**Summary:** {', '.join(summary_parts)}") + return "\n".join(lines) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Validate Errata Tool RPM advisory promotion (Phase 3)" + ) + parser.add_argument("version", + help="Version string, e.g., 4.18.3, 4.19.0") + parser.add_argument("advisory", + help="Errata Tool advisory ID or name " + "(e.g. 12345, RHBA-2026:12345)") + parser.add_argument("--verbose", action="store_true", + help="Show detailed markdown report") + parser.add_argument("--json", dest="json_output", action="store_true", + help="Output raw JSON") + return parser.parse_args() + + +def main(): + args = parse_args() + version_info = classify_version(args.version) + if version_info is None: + print(f"ERROR: Could not parse version string: {args.version!r}", + file=sys.stderr) + print("Expected formats: 4.18.3 | 4.19.0 | 4.19.0-ec.5 | 4.19.0-rc.2", + file=sys.stderr) + sys.exit(1) + + logger.info("Checking Errata Tool advisory %s for %s (%s)...", + args.advisory, args.version, version_info["type"]) + + results = run_errata_promotion_checks(version_info, args.advisory) + + if args.json_output: + output = { + "version": args.version, + "type": version_info["type"], + "minor": version_info["minor"], + "advisory": args.advisory, + "errata_checks": results, + } + print(json.dumps(output, indent=2)) + elif args.verbose: + print(format_text_full(args.version, args.advisory, results, version_info)) + else: + print(format_text_short(args.version, args.advisory, results)) + + if any(r["status"] == "FAIL" for r in results): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/microshift-release/scripts/errata_promotion.sh b/plugins/microshift-release/scripts/errata_promotion.sh new file mode 100755 index 00000000..84b8c0af --- /dev/null +++ b/plugins/microshift-release/scripts/errata_promotion.sh @@ -0,0 +1,22 @@ +#!/usr/bin/bash + +set -euo pipefail + +SCRIPTDIR="$(dirname "${BASH_SOURCE[0]}")" +REPOROOT="$(git rev-parse --show-toplevel)" +OUTPUT_DIR="${REPOROOT}/_output" +ENVDIR="${OUTPUT_DIR}/release_testing" + +if [[ ! -d "${ENVDIR}" ]]; then + echo "Setting up required tools..." >&2 + mkdir -p "${OUTPUT_DIR}" + python3 -m venv "${ENVDIR}" +fi + +MARKER="${ENVDIR}/.deps-installed" +if [[ ! -f "${MARKER}" ]] || [[ "${SCRIPTDIR}/requirements.txt" -nt "${MARKER}" ]]; then + "${ENVDIR}/bin/python3" -m pip install -q -r "${SCRIPTDIR}/requirements.txt" >&2 + touch "${MARKER}" +fi + +"${ENVDIR}/bin/python3" "${SCRIPTDIR}/errata_promotion.py" "$@" diff --git a/plugins/microshift-release/scripts/lib/errata.py b/plugins/microshift-release/scripts/lib/errata.py new file mode 100644 index 00000000..00a2ff5a --- /dev/null +++ b/plugins/microshift-release/scripts/lib/errata.py @@ -0,0 +1,279 @@ +"""Errata Tool REST API client for MicroShift RPM advisory validation. + +Authenticates via Kerberos/GSSAPI (requires a valid kinit session). +All requests go through the internal Red Hat VPN. +""" + +import logging +import re + +logger = logging.getLogger(__name__) + +ET_BASE_URL = "https://errata.devel.redhat.com" +ET_API_URL = f"{ET_BASE_URL}/api/v1" + +_session = None + + +def _get_session(): + """Return a requests session with GSSAPI auth, creating it on first call.""" + global _session + if _session is None: + import requests # noqa: PLC0415 + import urllib3 # noqa: PLC0415 + from requests_gssapi import HTTPSPNEGOAuth # noqa: PLC0415 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + _session = requests.Session() + _session.auth = HTTPSPNEGOAuth() + _session.verify = False + _session.headers.update({"Accept": "application/json"}) + return _session + + +def _et_get(path, **kwargs): + """GET from the Errata Tool API. + + Returns: + dict/list or None on failure. + """ + import requests # noqa: PLC0415 + session = _get_session() + url = f"{ET_API_URL}/{path.lstrip('/')}" + try: + resp = session.get(url, timeout=30, **kwargs) + if resp.status_code == 401: + logger.error("Kerberos auth failed (HTTP 401) — run 'kinit' first") + return None + if resp.status_code == 404: + logger.debug("ET API 404: %s", path) + return None + resp.raise_for_status() + return resp.json() + except requests.exceptions.ConnectionError as exc: + logger.error("Cannot reach Errata Tool — check VPN: %s", exc) + return None + except ValueError as exc: + logger.error("ET API returned non-JSON response for %s: %s", path, exc) + return None + except requests.RequestException as exc: + logger.error("ET API error for %s: %s", path, exc) + return None + + +def check_auth(): + """Verify Kerberos authentication against the Errata Tool. + + Returns: + bool + """ + import requests # noqa: PLC0415 + session = _get_session() + try: + resp = session.get(ET_BASE_URL, timeout=10, + headers={"Accept": "text/html"}) + if resp.status_code >= 400: + logger.error("Errata Tool auth check failed (HTTP %d)", resp.status_code) + return resp.status_code < 400 + except requests.exceptions.ConnectionError as exc: + logger.error("Cannot reach Errata Tool — check VPN: %s", exc) + return False + except requests.RequestException as exc: + logger.error("Errata Tool auth check failed: %s", exc) + return False + + +def _unwrap_advisory(data): + """Extract advisory fields from the ET response envelope. + + The ET ``/erratum/{id}`` response has the structure:: + + {"errata": {"rhba": {fields...}}, "original_type": "RHBA", ...} + + This extracts the inner advisory dict and merges in ``original_type``. + """ + if data is None: + return None + + errata = data.get("errata", {}) + advisory = None + errata_type = None + for key in ("rhba", "rhea", "rhsa"): + if key in errata: + advisory = errata[key] + errata_type = key.upper() + break + + if advisory is None: + for key in ("rhba", "rhea", "rhsa"): + if key in data: + advisory = data[key] + errata_type = key.upper() + break + + if advisory is None: + return None + + advisory["errata_type"] = (data.get("original_type") + or errata_type + or advisory.get("errata_type")) + return advisory + + +def fetch_advisory(advisory_id): + """Fetch advisory details. + + Args: + advisory_id: Numeric ID or name (e.g. ``RHBA-2026:12345``). + + Returns: + dict with advisory fields (including ``errata_type`` and + ``_jira_issues``), or None. + """ + data = _et_get(f"erratum/{advisory_id}") + advisory = _unwrap_advisory(data) + if advisory is not None and data is not None: + ji = data.get("jira_issues", {}) + if isinstance(ji, dict): + advisory["_jira_issues"] = ji.get("jira_issues", []) + return advisory + + +def fetch_builds(advisory_id): + """Fetch builds attached to an advisory. + + Returns: + dict: product-version name -> {name, description, builds}, or None. + """ + return _et_get(f"erratum/{advisory_id}/builds") + + +def fetch_jira_issues(advisory_id): + """Fetch Jira issues linked to an advisory. + + Returns: + list of issue dicts, or None. + """ + return _et_get(f"erratum/{advisory_id}/jira_issues") + + +def fetch_external_tests(advisory_id): + """Fetch external test results (CAT, rpmdiff, etc.). + + Returns: + list or dict of test results, or None. + """ + return _et_get(f"erratum/{advisory_id}/external_tests") + + +def fetch_cdn_repos(advisory_id): + """Fetch CDN repo status for an advisory. + + Returns: + dict of CDN repo info, or None. + """ + return _et_get(f"erratum/{advisory_id}/cdn_repos") + + +def extract_microshift_nvrs(builds_data): + """Extract MicroShift RPM filenames from the builds response. + + Handles the ET builds structure:: + + {"ProductVersion": {"name": "...", "builds": [{"nvr-string": {"variant_arch": ...}}]}} + + Extracts individual RPM filenames from ``variant_arch`` to capture + all subpackages (microshift-selinux, microshift-networking, etc.). + + Returns: + list of RPM filename strings, or empty list. + """ + if not builds_data: + return [] + + rpms = [] + for product_version, pv_data in builds_data.items(): + build_entries = pv_data + if isinstance(pv_data, dict): + build_entries = pv_data.get("builds", []) + if not isinstance(build_entries, list): + continue + for entry in build_entries: + if not isinstance(entry, dict): + continue + for nvr_key, build_info in entry.items(): + if "microshift" not in nvr_key.lower(): + continue + if not isinstance(build_info, dict): + rpms.append(nvr_key) + continue + variant_arch = build_info.get("variant_arch", {}) + found_rpms = False + for arches in variant_arch.values(): + if not isinstance(arches, dict): + continue + for rpm_list in arches.values(): + if not isinstance(rpm_list, list): + continue + for rpm_file in rpm_list: + if "microshift" in rpm_file.lower(): + rpms.append(rpm_file) + found_rpms = True + if not found_rpms: + rpms.append(nvr_key) + return sorted(set(rpms)) + + +def extract_package_names(nvrs): + """Extract RPM package names from NVR strings or RPM filenames. + + Args: + nvrs: List of NVR strings or RPM filenames, e.g. + ``["microshift-selinux-4.20.26-...el9.x86_64.rpm"]`` + + Returns: + set of package name strings. + """ + names = set() + for nvr in nvrs: + m = re.match(r"(microshift[a-z-]*)-\d+\.\d+\.\d+", nvr) + if m: + names.add(m.group(1)) + return names + + +def extract_bug_keys(jira_data): + """Extract OCPBUGS keys from the Jira issues response. + + Handles the ET embedded format where each item wraps + the issue fields under a ``jira_issue`` key:: + + [{"jira_issue": {"key": "OCPBUGS-123", "status": "Verified", ...}}] + + Returns: + list of dicts: ``[{"key": "OCPBUGS-123", "status": "Verified", ...}]`` + """ + if jira_data is None: + return [] + + bugs = [] + items = jira_data if isinstance(jira_data, list) else [] + + for item in items: + if not isinstance(item, dict): + continue + fields = item.get("jira_issue", item) + bug = {} + bug["key"] = fields.get("key") or fields.get("id_jira") or "" + status = fields.get("status") + if isinstance(status, dict): + bug["status"] = status.get("name", "unknown") + elif isinstance(status, str): + bug["status"] = status + else: + bug["status"] = "unknown" + bug["summary"] = fields.get("summary", "") + bug["is_private"] = fields.get("is_private", False) + if bug["key"] and bug["key"].startswith("OCPBUGS-"): + bugs.append(bug) + + return bugs diff --git a/plugins/microshift-release/scripts/unit_tests/test_errata_promotion.py b/plugins/microshift-release/scripts/unit_tests/test_errata_promotion.py new file mode 100644 index 00000000..882df7f7 --- /dev/null +++ b/plugins/microshift-release/scripts/unit_tests/test_errata_promotion.py @@ -0,0 +1,481 @@ +"""Unit tests for Errata Tool promotion checks (Phase 3).""" + +import sys +import os +import unittest +from unittest.mock import patch + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from errata_promotion import ( # noqa: E402 + check_advisory_exists, + check_advisory_type, + check_qa_owner, + check_bugs_verified, + check_rpms_present, + check_rpms_product_listed, + check_cdn_staging, + check_cat_tests, + check_status_rel_prep, + format_text_short, + format_text_full, + _expected_types, +) +from lib.errata import extract_microshift_nvrs, extract_package_names, extract_bug_keys # noqa: E402 +from validate_artifacts import classify_version # noqa: E402 + + +def _version(v="4.20.26"): + return classify_version(v) + + +def _advisory(status="QE", errata_type="RHBA", qa_name="MicroShift QE", + fulladvisory="RHBA-2026:12345-05", advisory_id=12345, + **kwargs): + adv = { + "id": advisory_id, + "fulladvisory": fulladvisory, + "status": status, + "errata_type": errata_type, + "quality_responsibility_name": qa_name, + } + adv.update(kwargs) + return adv + + +# ── Advisory exists ───────────────────────────────────────────── + + +class TestAdvisoryExists(unittest.TestCase): + def test_found(self): + r = check_advisory_exists(_advisory()) + self.assertEqual(r["status"], "PASS") + self.assertIn("RHBA-2026:12345", r["reason"]) + + def test_not_found(self): + r = check_advisory_exists(None) + self.assertEqual(r["status"], "FAIL") + + +# ── Advisory type ─────────────────────────────────────────────── + + +class TestAdvisoryType(unittest.TestCase): + def test_zstream_rhba(self): + r = check_advisory_type(_advisory(errata_type="RHBA"), _version("4.20.26")) + self.assertEqual(r["status"], "PASS") + + def test_zstream_rhsa(self): + r = check_advisory_type(_advisory(errata_type="RHSA"), _version("4.20.26")) + self.assertEqual(r["status"], "PASS") + + def test_zstream_wrong_type(self): + r = check_advisory_type(_advisory(errata_type="RHEA"), _version("4.20.26")) + self.assertEqual(r["status"], "FAIL") + + def test_ga_rhea(self): + r = check_advisory_type(_advisory(errata_type="RHEA"), _version("4.22.0")) + self.assertEqual(r["status"], "PASS") + + def test_ga_wrong_type(self): + r = check_advisory_type(_advisory(errata_type="RHBA"), _version("4.22.0")) + self.assertEqual(r["status"], "FAIL") + + def test_none_advisory(self): + r = check_advisory_type(None, _version()) + self.assertEqual(r["status"], "WARN") + + +class TestExpectedTypes(unittest.TestCase): + def test_xy(self): + self.assertEqual(_expected_types(_version("4.22.0")), ["RHEA"]) + + def test_z(self): + self.assertEqual(_expected_types(_version("4.20.26")), ["RHBA", "RHSA"]) + + def test_ec(self): + self.assertEqual(_expected_types(_version("5.0.0-ec.3")), ["RHBA", "RHSA"]) + + def test_rc(self): + self.assertEqual(_expected_types(_version("4.22.0-rc.2")), ["RHBA", "RHSA"]) + + +# ── QA owner ──────────────────────────────────────────────────── + + +class TestQAOwner(unittest.TestCase): + def test_changed(self): + r = check_qa_owner(_advisory(qa_name="MicroShift QE")) + self.assertEqual(r["status"], "PASS") + self.assertIn("MicroShift QE", r["reason"]) + + def test_default(self): + r = check_qa_owner(_advisory(qa_name="Default")) + self.assertEqual(r["status"], "FAIL") + + def test_empty(self): + r = check_qa_owner(_advisory(qa_name="")) + self.assertEqual(r["status"], "FAIL") + + def test_numeric_id_set(self): + r = check_qa_owner(_advisory(qa_name="", quality_responsibility_id=139)) + self.assertEqual(r["status"], "PASS") + self.assertIn("139", r["reason"]) + + def test_numeric_id_zero(self): + r = check_qa_owner(_advisory(qa_name="", quality_responsibility_id=0)) + self.assertEqual(r["status"], "FAIL") + + def test_none_advisory(self): + r = check_qa_owner(None) + self.assertEqual(r["status"], "WARN") + + +# ── Bugs verified ────────────────────────────────────────────── + + +class TestBugsVerified(unittest.TestCase): + def test_all_verified(self): + bugs = [ + {"key": "OCPBUGS-1", "status": "Verified"}, + {"key": "OCPBUGS-2", "status": "Verified"}, + ] + r = check_bugs_verified(bugs) + self.assertEqual(r["status"], "PASS") + self.assertIn("2/2", r["reason"]) + self.assertIn("accepted", r["reason"]) + + def test_all_closed(self): + bugs = [ + {"key": "OCPBUGS-1", "status": "Closed"}, + {"key": "OCPBUGS-2", "status": "Closed"}, + ] + r = check_bugs_verified(bugs) + self.assertEqual(r["status"], "PASS") + + def test_mixed_verified_closed(self): + bugs = [ + {"key": "OCPBUGS-1", "status": "Verified"}, + {"key": "OCPBUGS-2", "status": "Closed"}, + ] + r = check_bugs_verified(bugs) + self.assertEqual(r["status"], "PASS") + + def test_some_not_verified(self): + bugs = [ + {"key": "OCPBUGS-1", "status": "Verified"}, + {"key": "OCPBUGS-2", "status": "Modified"}, + {"key": "OCPBUGS-3", "status": "ON_QA"}, + ] + r = check_bugs_verified(bugs) + self.assertEqual(r["status"], "FAIL") + self.assertIn("1/3", r["reason"]) + + def test_no_bugs(self): + r = check_bugs_verified([]) + self.assertEqual(r["status"], "PASS") + + def test_none_data(self): + r = check_bugs_verified(None) + self.assertEqual(r["status"], "WARN") + + +# ── RPMs present ─────────────────────────────────────────────── + + +class TestRPMsPresent(unittest.TestCase): + @patch("errata_promotion.artifacts.get_expected_packages", + return_value=["microshift", "microshift-selinux", "microshift-networking"]) + def test_all_found(self, _mock): + nvrs = [ + "microshift-4.20.26-202607010000.p0.gabc1234.assembly.4.20.26.el9", + "microshift-selinux-4.20.26-202607010000.p0.gabc1234.assembly.4.20.26.el9", + "microshift-networking-4.20.26-202607010000.p0.gabc1234.assembly.4.20.26.el9", + ] + r = check_rpms_present(nvrs, _version("4.20.26")) + self.assertEqual(r["status"], "PASS") + self.assertIn("3/3", r["reason"]) + + @patch("errata_promotion.artifacts.get_expected_packages", + return_value=["microshift", "microshift-selinux", "microshift-networking"]) + def test_missing_rpms(self, _mock): + nvrs = [ + "microshift-4.20.26-202607010000.p0.gabc1234.assembly.4.20.26.el9", + ] + r = check_rpms_present(nvrs, _version("4.20.26")) + self.assertEqual(r["status"], "FAIL") + self.assertIn("2", r["reason"]) + + @patch("errata_promotion.artifacts.get_expected_packages", return_value=None) + def test_no_expected_packages(self, _mock): + nvrs = ["microshift-4.20.26-el9"] + r = check_rpms_present(nvrs, _version("4.20.26")) + self.assertEqual(r["status"], "WARN") + + def test_no_nvrs(self): + r = check_rpms_present([], _version()) + self.assertEqual(r["status"], "FAIL") + + +# ── RPMs product listed ───────────────────────────────────────── + + +class TestRPMsProductListed(unittest.TestCase): + def test_listed_nested(self): + builds_data = { + "OSE-4.22-RHEL-9": { + "name": "OSE-4.22-RHEL-9", + "builds": [ + {"microshift-4.22.7-el9": {"nvr": "microshift-4.22.7-el9"}} + ] + } + } + nvrs = ["microshift-4.22.7-el9"] + r = check_rpms_product_listed(builds_data, nvrs) + self.assertEqual(r["status"], "PASS") + self.assertIn("OSE-4.22-RHEL-9", r["details"]) + + def test_listed_flat(self): + builds_data = { + "RHEL-9-OSE-4.20": [ + {"microshift-4.20.26-el9": {"nvr": "microshift-4.20.26-el9"}} + ] + } + nvrs = ["microshift-4.20.26-el9"] + r = check_rpms_product_listed(builds_data, nvrs) + self.assertEqual(r["status"], "PASS") + + def test_not_listed(self): + builds_data = { + "OSE-4.22-RHEL-9": { + "name": "OSE-4.22-RHEL-9", + "builds": [ + {"other-package-4.20.26-el9": {"nvr": "other-package-el9"}} + ] + } + } + nvrs = ["microshift-4.20.26-el9"] + r = check_rpms_product_listed(builds_data, nvrs) + self.assertEqual(r["status"], "FAIL") + + def test_no_data(self): + r = check_rpms_product_listed(None, []) + self.assertEqual(r["status"], "WARN") + + +# ── CDN staging ───────────────────────────────────────────────── + + +class TestCDNStaging(unittest.TestCase): + def test_rel_prep(self): + r = check_cdn_staging(_advisory(status="REL_PREP")) + self.assertEqual(r["status"], "PASS") + + def test_qe_status(self): + r = check_cdn_staging(_advisory(status="QE")) + self.assertEqual(r["status"], "WARN") + + def test_text_only(self): + r = check_cdn_staging(_advisory(text_only=True)) + self.assertEqual(r["status"], "SKIP") + + def test_none(self): + r = check_cdn_staging(None) + self.assertEqual(r["status"], "WARN") + + +# ── CAT tests ────────────────────────────────────────────────── + + +class TestCATTests(unittest.TestCase): + def test_rhnqa_passed(self): + r = check_cat_tests(_advisory(rhnqa=1)) + self.assertEqual(r["status"], "PASS") + + def test_qa_complete(self): + r = check_cat_tests(_advisory(rhnqa=0, qa_complete=1)) + self.assertEqual(r["status"], "PASS") + + def test_rhnqa_not_passed_qe(self): + r = check_cat_tests(_advisory(status="QE", rhnqa=0, qa_complete=0)) + self.assertEqual(r["status"], "FAIL") + + def test_rel_prep_fallback(self): + r = check_cat_tests(_advisory(status="REL_PREP", rhnqa=0)) + self.assertEqual(r["status"], "PASS") + + def test_none_advisory(self): + r = check_cat_tests(None) + self.assertEqual(r["status"], "WARN") + + +# ── Status REL_PREP ───────────────────────────────────────────── + + +class TestStatusRelPrep(unittest.TestCase): + def test_rel_prep(self): + r = check_status_rel_prep(_advisory(status="REL_PREP")) + self.assertEqual(r["status"], "PASS") + + def test_push_ready(self): + r = check_status_rel_prep(_advisory(status="PUSH_READY")) + self.assertEqual(r["status"], "PASS") + + def test_shipped(self): + r = check_status_rel_prep(_advisory(status="SHIPPED_LIVE")) + self.assertEqual(r["status"], "PASS") + + def test_qe(self): + r = check_status_rel_prep(_advisory(status="QE")) + self.assertEqual(r["status"], "FAIL") + + def test_new_files(self): + r = check_status_rel_prep(_advisory(status="NEW_FILES")) + self.assertEqual(r["status"], "FAIL") + + def test_none(self): + r = check_status_rel_prep(None) + self.assertEqual(r["status"], "WARN") + + +# ── lib/errata helpers ────────────────────────────────────────── + + +class TestExtractMicroshiftNVRs(unittest.TestCase): + def test_variant_arch_format(self): + builds = { + "OSE-4.22-RHEL-9": { + "name": "OSE-4.22-RHEL-9", + "builds": [{ + "microshift-4.22.7-202607.el9": { + "nvr": "microshift-4.22.7-202607.el9", + "variant_arch": { + "9Base-RHOSE-4.22": { + "x86_64": [ + "microshift-4.22.7-202607.el9.x86_64.rpm", + "microshift-selinux-4.22.7-202607.el9.x86_64.rpm", + ], + "noarch": [ + "microshift-greenboot-4.22.7-202607.el9.noarch.rpm", + ], + } + }, + }, + "other-pkg-4.22.7-el9": {"nvr": "other-pkg-4.22.7-el9"}, + }] + } + } + nvrs = extract_microshift_nvrs(builds) + self.assertIn("microshift-4.22.7-202607.el9.x86_64.rpm", nvrs) + self.assertIn("microshift-selinux-4.22.7-202607.el9.x86_64.rpm", nvrs) + self.assertIn("microshift-greenboot-4.22.7-202607.el9.noarch.rpm", nvrs) + self.assertFalse(any("other-pkg" in n for n in nvrs)) + + def test_no_variant_arch_fallback(self): + builds = { + "RHEL-9-OSE-4.20": [ + {"microshift-4.20.26-202607.el9": {"nvr": "microshift-4.20.26-202607.el9"}}, + ] + } + nvrs = extract_microshift_nvrs(builds) + self.assertEqual(nvrs, ["microshift-4.20.26-202607.el9"]) + + def test_empty(self): + self.assertEqual(extract_microshift_nvrs(None), []) + self.assertEqual(extract_microshift_nvrs({}), []) + + +class TestExtractPackageNames(unittest.TestCase): + def test_basic(self): + nvrs = [ + "microshift-4.20.26-202607010000.p0.gabc1234.assembly.4.20.26.el9", + "microshift-selinux-4.20.26-202607010000.p0.gabc1234.assembly.4.20.26.el9", + "microshift-networking-4.20.26-202607010000.p0.gabc1234.assembly.4.20.26.el9", + ] + names = extract_package_names(nvrs) + self.assertEqual(names, {"microshift", "microshift-selinux", "microshift-networking"}) + + def test_empty(self): + self.assertEqual(extract_package_names([]), set()) + + +class TestExtractBugKeys(unittest.TestCase): + def test_list_format(self): + data = [ + {"key": "OCPBUGS-1", "status": "Verified", "summary": "Bug 1"}, + {"key": "OCPBUGS-2", "status": "Modified", "summary": "Bug 2"}, + ] + bugs = extract_bug_keys(data) + self.assertEqual(len(bugs), 2) + self.assertEqual(bugs[0]["key"], "OCPBUGS-1") + self.assertEqual(bugs[0]["status"], "Verified") + + def test_nested_status(self): + data = [ + {"key": "OCPBUGS-1", "status": {"name": "Verified"}, "summary": "Bug 1"}, + ] + bugs = extract_bug_keys(data) + self.assertEqual(bugs[0]["status"], "Verified") + + def test_jira_issue_key(self): + data = [ + {"jira_issue": {"key": "OCPBUGS-1", "summary": "Bug 1"}, "status": "Verified"}, + ] + bugs = extract_bug_keys(data) + self.assertEqual(bugs[0]["key"], "OCPBUGS-1") + + def test_none(self): + self.assertEqual(extract_bug_keys(None), []) + + def test_empty(self): + self.assertEqual(extract_bug_keys([]), []) + + +# ── Formatting ────────────────────────────────────────────────── + + +class TestFormatShort(unittest.TestCase): + def test_sections(self): + results = [ + {"check": "et_advisory_exists", "status": "PASS", + "reason": "Advisory found", "details": []}, + {"check": "et_bugs_verified", "status": "PASS", + "reason": "3/3 verified", "details": []}, + {"check": "et_rpms_present", "status": "PASS", + "reason": "8/8 RPMs", "details": []}, + {"check": "et_cdn_staging", "status": "PASS", + "reason": "CDN push completed", "details": []}, + ] + out = format_text_short("4.20.26", "RHBA-2026:12345", results) + self.assertIn("── Advisory", out) + self.assertIn("── Bugs", out) + self.assertIn("── Builds", out) + self.assertIn("── Distribution", out) + self.assertIn("Errata Tool Promotion", out) + + def test_fail_details_shown(self): + results = [ + {"check": "et_bugs_verified", "status": "FAIL", + "reason": "1/3 verified", "details": ["OCPBUGS-2: Modified"]}, + ] + out = format_text_short("4.20.26", "RHBA-2026:12345", results) + self.assertIn("OCPBUGS-2: Modified", out) + + +class TestFormatFull(unittest.TestCase): + def test_markdown(self): + results = [ + {"check": "et_advisory_exists", "status": "PASS", + "reason": "Advisory found", "details": []}, + {"check": "et_rpms_present", "status": "PASS", + "reason": "8/8 RPMs", "details": []}, + ] + vi = _version("4.20.26") + out = format_text_full("4.20.26", "RHBA-2026:12345", results, vi) + self.assertIn("## Advisory", out) + self.assertIn("## Builds", out) + self.assertIn("**Summary:**", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/microshift-release/skills/advisory-promotion/SKILL.md b/plugins/microshift-release/skills/advisory-promotion/SKILL.md index 08683f52..1553b33d 100644 --- a/plugins/microshift-release/skills/advisory-promotion/SKILL.md +++ b/plugins/microshift-release/skills/advisory-promotion/SKILL.md @@ -1,7 +1,7 @@ --- name: microshift-release:advisory-promotion -argument-hint: [--prod] [--verbose] -description: Validate Konflux bootc advisory promotion for QE sign-off — verify advisory YAML, catalog presence, shipment MR, and commit provenance +argument-hint: [--prod] [--verbose] [--json] [--errata ] +description: Validate advisory promotion for QE sign-off — Konflux bootc images (default) or Errata Tool RPM advisory (--errata) user-invocable: true allowed-tools: Bash --- @@ -12,11 +12,21 @@ allowed-tools: Bash ```bash /microshift-release:advisory-promotion [--prod] [--verbose] +/microshift-release:advisory-promotion --errata [--verbose] ``` ## Description -Phase 3 of the MicroShift release process: verify that a Konflux-built bootc advisory is ready for QE sign-off and shipping. Validates three data sources: +Phase 3 of the MicroShift release process: verify that advisories are ready for QE sign-off and shipping. + +**Two modes:** + +1. **Bootc mode (default)** — validates Konflux-built bootc image advisory via advisory YAML, Pyxis catalog, and shipment MR +2. **Errata mode (`--errata`)** — validates RPM advisory in the Errata Tool via its REST API + +### Bootc Mode + +Validates three data sources: - **advisory.yaml** — image presence, repository, SHA, and advisory type - **Pyxis catalog** (via GraphQL) — stage and prod catalog presence, assembly tags, commit provenance @@ -26,13 +36,25 @@ All per-image checks run independently per variant (arch + RHEL version). For ve Supports Z-stream, X/Y GA, RC, and EC release types. Requires version 4.18+. +### Errata Mode + +Validates the MicroShift RPM advisory in the Red Hat Errata Tool: + +- **Advisory metadata** — exists, correct type (RHEA for GA, RHBA/RHSA for z-stream), QA ownership changed +- **Bugs** — all linked OCPBUGS in Verified state +- **Builds** — all MicroShift RPMs attached and mapped to product listings +- **Distribution** — CDN staging push completed, CAT tests passing, advisory moved to REL_PREP + +Requires VPN and a valid Kerberos ticket (`kinit`). + ## Prerequisites | Requirement | Needed for | Mandatory? | |---|---|---| -| VPN | GitLab API, Brew RPM lookup | Yes — shipment and NVR checks WARN without it | -| `GITLAB_API_TOKEN` | Shipment MR fetch, MR approval check | Yes — shipment checks WARN without it | -| Internet | Pyxis catalog queries (GraphQL) | Yes for catalog checks | +| VPN | GitLab API, Brew, Errata Tool | Yes | +| `GITLAB_API_TOKEN` | Bootc: shipment MR, approval check | Yes (bootc mode) | +| Kerberos ticket (`kinit`) | Errata Tool API | Yes (errata mode) | +| Internet | Pyxis catalog queries (GraphQL) | Yes (bootc mode) | ## Arguments @@ -41,8 +63,9 @@ Supports Z-stream, X/Y GA, RC, and EC release types. Requires version 4.18+. - X/Y (GA): `4.22.0` - RC: `4.22.0-rc.2` - EC: `5.0.0-ec.3` -- `--prod` (optional): Check both stage and prod catalogs (default: stage only, prod checks skipped) -- `--verbose` (optional): Show detailed markdown report with evidence per check +- `--errata ` (optional): Switch to Errata Tool mode. `advisory_id` is the ET advisory numeric ID or name (e.g., `12345` or `RHBA-2026:12345`) +- `--prod` (optional, bootc only): Check both stage and prod catalogs (default: stage only) +- `--verbose` (optional): Show detailed markdown report ## Scripts Directory @@ -55,33 +78,56 @@ SCRIPTS_DIR=plugins/microshift-release/scripts ### Step 1: Parse Arguments 1. Extract `version` from `$ARGUMENTS` — the first non-flag token -2. Pass through `--prod`, `--verbose`, and `--json` flags if present +2. Check if `--errata` is present: + - If present, extract the `` (the token immediately after `--errata`) + - Route to **Errata mode** (Step 2b) + - If `--errata` is present but no advisory ID follows, error: "Missing advisory ID after --errata" +3. If `--errata` is not present, route to **Bootc mode** (Step 2a) +4. Pass through `--verbose` and `--json` flags if present -### Step 2: Run the Script +### Step 2a: Run Bootc Checks (default) ```bash -bash $SCRIPTS_DIR/advisory_promotion.sh [--prod] [--verbose] +bash $SCRIPTS_DIR/advisory_promotion.sh --prod [--verbose] +``` + +Always pass `--prod` so that all checks run (stage and prod catalogs). Display stderr only if the script exits non-zero. + +### Step 2b: Run Errata Tool Checks + +```bash +bash $SCRIPTS_DIR/errata_promotion.sh [--verbose] ``` Display stderr only if the script exits non-zero. ### Step 3: Display Output -Display output **verbatim** — do not reformat, summarize, or add commentary. The script produces deterministic pre-formatted text. +Paste the **complete stdout** of each script into the response as a code block. Every check line must be visible to the user. Do not summarize, abbreviate, or replace the output with commentary. ### Step 4: Handle Errors If the script exits non-zero: +**Bootc mode:** + - **VPN errors**: Connect to VPN (GitLab API and Brew require it) - **Missing GITLAB_API_TOKEN**: `export GITLAB_API_TOKEN=` for shipment MR and approval checks - **Version too low**: Advisory promotion requires 4.18+ (Konflux builds) +**Errata mode:** + +- **Kerberos auth failed**: Run `kinit @REDHAT.COM` to obtain a Kerberos ticket +- **VPN errors**: Connect to VPN (Errata Tool requires it) +- **Advisory not found**: Verify the advisory ID/name is correct in the Errata Tool UI + ## Checks Performed +### Bootc Mode Checks + All per-image checks run independently per variant (`{arch}_el{rhel}`). For versions < 4.22.2 only el9 is checked; for 4.22.2+ both el9 and el10. -### Per-variant checks (`{arch}_el{rhel}_*`) +#### Per-variant checks (`{arch}_el{rhel}_*`) **From advisory.yaml** (`rhtap-release/advisories/.../advisory.yaml`): @@ -106,7 +152,7 @@ All per-image checks run independently per variant (`{arch}_el{rhel}`). For vers | `{v}_catalog_prod_no_xy0_tag` | Z-stream only: no X.Y.0 assembly tag on prod image | | `{v}_catalog_prod_chi` | Container Health Index grade is A (prod) | -### Global checks +#### Global checks **From advisory.yaml:** @@ -120,14 +166,28 @@ All per-image checks run independently per variant (`{arch}_el{rhel}`). For vers | Check | Description | |---|---| | `shipment_type` | `releaseNotes.type` matches expected advisory type | -| `shipment_errata_stage_url` | Stage errata URL present and its images match the advisory | -| `shipment_errata_prod_url` | Prod errata URL present and its images match the advisory (skipped for EC/RC) | | `shipment_filename` | YAML path matches `shipment/ocp/openshift-{minor}/.../{version}.microshift-bootc.{timestamp}.yaml` | | `shipment_nvr_commit` | Commit hash in shipment `snapshot.nvrs` matches the Brew RPM build commit | | `shipment_mr_approved` | Shipment MR has required approvals | +### Errata Mode Checks + +| Check | Section | Description | +|---|---|---| +| `et_advisory_exists` | Advisory | Advisory found and fetchable from the Errata Tool | +| `et_advisory_type` | Advisory | Advisory type is RHEA (GA) or RHBA/RHSA (z-stream, EC, RC) | +| `et_qa_owner` | Advisory | QA ownership changed from default | +| `et_status_rel_prep` | Advisory | Advisory status is REL_PREP (or later) | +| `et_bugs_verified` | Bugs | All linked OCPBUGS are in Verified state | +| `et_rpms_present` | Builds | All expected MicroShift RPMs attached to advisory | +| `et_rpms_product_listed` | Builds | MicroShift RPMs mapped to product version listings | +| `et_cdn_staging` | Distribution | CDN staging push completed | +| `et_cat_tests` | Distribution | RHN QA testing passed (rhnqa field) | + ## Output Format +### Bootc Mode Output + **Short (default):** All checks shown, grouped by variant. Skipped checks use ⏭️. ```text @@ -137,78 +197,68 @@ Advisory Promotion: 4.20.26 ✅ amd64_el9_advisory_image_present amd64/el9 present ✅ amd64_el9_advisory_repository registry.stage.redhat.io/openshift4/microshift-bootc-rhel9 ✅ amd64_el9_advisory_image_sha sha256:f839eb91f716 -✅ amd64_el9_catalog_stage_present Found in stage catalog -✅ amd64_el9_catalog_stage_tag_commit Commit b79e4b0 matches catalog -✅ amd64_el9_catalog_stage_tag_date 2026-06-19 09:02 -✅ amd64_el9_catalog_stage_no_xy0_tag No 4.20.0 tags (7 checked) -✅ amd64_el9_catalog_stage_chi CHI grade A -⏭️ amd64_el9_catalog_prod_present N/A (stage mode) -⏭️ amd64_el9_catalog_prod_tag_commit N/A (prod not queried) -⏭️ amd64_el9_catalog_prod_tag_date N/A (prod not queried) -⏭️ amd64_el9_catalog_prod_no_xy0_tag N/A (prod not queried) -⏭️ amd64_el9_catalog_prod_chi N/A (prod not queried) - -── arm64_el9 ─────────────────────────────────────────────── -✅ arm64_el9_advisory_image_present arm64/el9 present ... ── Global ────────────────────────────────────────────────── -✅ advisory_type spec.type = RHBA -✅ shipment_type releaseNotes.type = RHBA -✅ shipment_errata_stage_url RHBA-2026:73871 — 4 images verified -✅ shipment_errata_prod_url RHBA-2026:47055 — 4 images verified -✅ shipment_filename shipment/ocp/openshift-4.20/openshift-4-20/prod/4.20.26.microshift-bootc...yaml -✅ shipment_nvr_commit Commit b79e4b0 matches Brew -✅ advisory_sha_distinct_el9 SHAs are distinct -✅ shipment_mr_approved MR !594 approved by tlove, knarra, adobes +✅ advisory_type spec.type = RHBA +✅ shipment_mr_approved MR !594 approved by tlove, knarra, adobes ``` -With `--prod`, both stage and prod catalog checks run: +### Errata Mode Output + +**Short (default):** ```text -✅ amd64_el9_catalog_stage_present Found in stage catalog -✅ amd64_el9_catalog_stage_chi CHI grade A -✅ amd64_el9_catalog_prod_present Found in prod catalog -✅ amd64_el9_catalog_prod_chi CHI grade A -``` +Errata Tool Promotion: 4.20.26 (RHBA-2026:12345) -On failure, details appear below the failing check: +── Advisory ──────────────────────────────────────────────── +✅ et_advisory_exists Advisory found: RHBA-2026:12345-05 +✅ et_advisory_type RHBA (expected for Z) +✅ et_qa_owner QA: MicroShift QE +✅ et_status_rel_prep Status: REL_PREP -```text -❌ amd64_el9_advisory_repository Wrong repository: registry.redhat.io/openshift4/microshift-bootc-rhel9 - Expected: registry.stage.redhat.io/openshift4/microshift-bootc-rhel9 - Got: registry.redhat.io/openshift4/microshift-bootc-rhel9 +── Bugs ──────────────────────────────────────────────────── +✅ et_bugs_verified 3/3 bugs in Verified state + +── Builds ────────────────────────────────────────────────── +✅ et_rpms_present 8/8 MicroShift RPMs in advisory +✅ et_rpms_product_listed RPMs listed in 2 product version(s) + +── Distribution ──────────────────────────────────────────── +✅ et_cdn_staging CDN staging push completed (status: REL_PREP) +✅ et_cat_tests 2 external test(s) passing ``` -For EC/RC, prod catalog checks are always skipped: +On failure, details appear below the failing check: ```text -⏭️ amd64_el9_catalog_prod_present N/A (EC not shipped to prod) +❌ et_bugs_verified 1/3 verified — 2 not yet verified + OCPBUGS-12345: Modified + OCPBUGS-67890: ON_QA ``` -**Verbose (--verbose):** Markdown table with full evidence per check, grouped by variant. +**Verbose (--verbose):** Markdown table with full evidence per check. ## Examples ```bash +# Bootc mode /microshift-release:advisory-promotion 4.20.26 # Z-stream (el9 only) /microshift-release:advisory-promotion 4.22.2 # Z-stream (el9 + el10) /microshift-release:advisory-promotion 4.22.0 # X/Y GA -/microshift-release:advisory-promotion 5.0.0-ec.3 # Engineering Candidate -/microshift-release:advisory-promotion 4.22.0-rc.2 # Release Candidate +/microshift-release:advisory-promotion 4.20.26 --prod # check prod catalog too /microshift-release:advisory-promotion 4.20.26 --verbose # detailed report + +# Errata Tool mode +/microshift-release:advisory-promotion 4.20.26 --errata 12345 +/microshift-release:advisory-promotion 4.20.26 --errata RHBA-2026:12345 +/microshift-release:advisory-promotion 4.22.0 --errata RHEA-2026:67890 --verbose ``` ## Notes - Read-only — does NOT modify advisories, tickets, or external state. No confirmation required. -- VPN is required for GitLab API access and Brew RPM commit comparison -- GITLAB_API_TOKEN enables shipment MR checks; without it those checks show WARN -- Catalog checks use the Pyxis GraphQL API (works for both stage and prod) -- Default mode is stage — prod catalog checks are skipped. Use `--prod` to check both catalogs -- For EC/RC, `catalog_prod_present` is always skipped (not shipped to prod) -- For versions 4.22.2+, el10 bootc images are also checked -- Repository names are version-aware: `openshift4` for 4.x, `openshift5` for 5.x -- Only supports versions 4.18+ (Konflux bootc builds) -- Nightly builds are skipped (no advisories) +- **Bootc mode**: VPN + GITLAB_API_TOKEN required; version 4.18+ only +- **Errata mode**: VPN + Kerberos ticket (`kinit`) required; works with any MicroShift version that has an ET advisory - Exit code is non-zero if any check returns FAIL +- Both modes support `--verbose` for detailed markdown output and `--json` for machine-readable JSON