From 43c601d3565395ce430c1becf7b214815efdffba Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Sat, 9 May 2026 02:51:39 +0200 Subject: [PATCH 01/14] Add auto-merge workflow for docs PRs based on Learn Build bot comments - New workflow (automerge-docs.yml) triggers on issue_comment from learn-build-service-prod bot on automation/update-api-docs PRs - Checks PoliCheck (must show 'No issues found') and Build Report (no errors, no new warnings vs baseline) - Uses the JSON build log API for reliable structured warning data - Baseline file (known-warnings.txt) tracks 120 known xref-not-found warnings from external library type references - Fix update-docs.yml to skip force-push when automation branch already has identical content (compares git tree SHAs) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/known-warnings.txt | 120 +++++++++++ .github/scripts/check-learn-build.py | 309 +++++++++++++++++++++++++++ .github/workflows/automerge-docs.yml | 49 +++++ 3 files changed, 478 insertions(+) create mode 100644 .github/known-warnings.txt create mode 100644 .github/scripts/check-learn-build.py create mode 100644 .github/workflows/automerge-docs.yml diff --git a/.github/known-warnings.txt b/.github/known-warnings.txt new file mode 100644 index 00000000..821f80f4 --- /dev/null +++ b/.github/known-warnings.txt @@ -0,0 +1,120 @@ +SkiaSharpAPI/SkiaSharp.Views.Desktop/SKGLControl.xml|xref-not-found|Cross reference not found: 'OpenTK.GLControl'. +SkiaSharpAPI/SkiaSharp.Views.Desktop/SKGLControl.xml|xref-not-found|Cross reference not found: 'OpenTK.GLControl.GLControl'. +SkiaSharpAPI/SkiaSharp.Views.Desktop/SKGLControl.xml|xref-not-found|Cross reference not found: 'OpenTK.GLControl.GLControlSettings'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Color'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Color'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Color'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Color'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Point'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Point'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Point'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Point'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.RGBA'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.RGBA'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Rectangle'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Rectangle'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Rectangle'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Rectangle'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Size'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Size'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Size'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Size'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Graphene.Point'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Graphene.Point3D'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Graphene.Rect'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Graphene.Size'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKDrawingArea.xml|xref-not-found|Cross reference not found: 'Cairo.Context'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKDrawingArea.xml|xref-not-found|Cross reference not found: 'Cairo.Context'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKDrawingArea.xml|xref-not-found|Cross reference not found: 'Cairo.Context'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKDrawingArea.xml|xref-not-found|Cross reference not found: 'Gtk.DrawingArea'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKWidget.xml|xref-not-found|Cross reference not found: 'Gdk.EventExpose'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKWidget.xml|xref-not-found|Cross reference not found: 'Gdk.EventExpose'. +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKWidget.xml|xref-not-found|Cross reference not found: 'Gtk.DrawingArea'. +SkiaSharpAPI/SkiaSharp.Views.Tizen.NUI/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.BaseComponents.ImageView'. +SkiaSharpAPI/SkiaSharp.Views.Tizen.NUI/SKCanvasView.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.BaseComponents.ImageView'. +SkiaSharpAPI/SkiaSharp.Views.Tizen.NUI/SKGLSurfaceView.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.BaseComponents.ImageView'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Widget'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKCanvasView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKCanvasView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKCanvasView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Widget'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Widget'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Color'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Color'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Point'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Point'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Point'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Point'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Size'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Size'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Size'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Size'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Color'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Position'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Position2D'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Rectangle'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Rectangle'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Size'. +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Size2D'. +SkiaSharpAPI/SkiaSharp.Views.UWP/AngleSwapChainPanel.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Controls.SwapChainPanel'. +SkiaSharpAPI/SkiaSharp.Views.UWP/SKSwapChainPanel.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Controls.SwapChainPanel'. +SkiaSharpAPI/SkiaSharp.Views.UWP/SKXamlCanvas.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Controls.Canvas'. +SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.WPF/SKGLElement.xml|xref-not-found|Cross reference not found: 'OpenTK.Wpf.GLWpfControl'. +SkiaSharpAPI/SkiaSharp.Views.Windows/AngleSwapChainPanel.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Controls.SwapChainPanel'. +SkiaSharpAPI/SkiaSharp.Views.Windows/SKSwapChainPanel.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Controls.SwapChainPanel'. +SkiaSharpAPI/SkiaSharp.Views.Windows/SKXamlCanvas.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Controls.Canvas'. +SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. +SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml|xref-not-found|Cross reference not found: 'SharpVk.Device'. +SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml|xref-not-found|Cross reference not found: 'SharpVk.Instance'. +SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml|xref-not-found|Cross reference not found: 'SharpVk.PhysicalDevice'. +SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml|xref-not-found|Cross reference not found: 'SharpVk.PhysicalDeviceFeatures'. +SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml|xref-not-found|Cross reference not found: 'SharpVk.Queue'. +SkiaSharpAPI/SkiaSharp/GRSharpVkGetProcedureAddressDelegate.xml|xref-not-found|Cross reference not found: 'SharpVk.Device'. +SkiaSharpAPI/SkiaSharp/GRSharpVkGetProcedureAddressDelegate.xml|xref-not-found|Cross reference not found: 'SharpVk.Instance'. +SkiaSharpAPI/SkiaSharp/GRVkExtensionsSharpVkExtensions.xml|xref-not-found|Cross reference not found: 'SharpVk.Instance'. +SkiaSharpAPI/SkiaSharp/GRVkExtensionsSharpVkExtensions.xml|xref-not-found|Cross reference not found: 'SharpVk.Instance'. +SkiaSharpAPI/SkiaSharp/GRVkExtensionsSharpVkExtensions.xml|xref-not-found|Cross reference not found: 'SharpVk.PhysicalDevice'. +SkiaSharpAPI/SkiaSharp/GRVkExtensionsSharpVkExtensions.xml|xref-not-found|Cross reference not found: 'SharpVk.PhysicalDevice'. +SkiaSharpAPI/SkiaSharp/GRVorticeD3DBackendContext.xml|xref-not-found|Cross reference not found: 'Vortice.DXGI.IDXGIAdapter1'. +SkiaSharpAPI/SkiaSharp/GRVorticeD3DBackendContext.xml|xref-not-found|Cross reference not found: 'Vortice.Direct3D12.ID3D12CommandQueue'. +SkiaSharpAPI/SkiaSharp/GRVorticeD3DBackendContext.xml|xref-not-found|Cross reference not found: 'Vortice.Direct3D12.ID3D12Device2'. +SkiaSharpAPI/SkiaSharp/GRVorticeD3DTextureResourceInfo.xml|xref-not-found|Cross reference not found: 'Vortice.DXGI.Format'. +SkiaSharpAPI/SkiaSharp/GRVorticeD3DTextureResourceInfo.xml|xref-not-found|Cross reference not found: 'Vortice.Direct3D12.ID3D12Resource'. +SkiaSharpAPI/SkiaSharp/GRVorticeD3DTextureResourceInfo.xml|xref-not-found|Cross reference not found: 'Vortice.Direct3D12.ResourceStates'. diff --git a/.github/scripts/check-learn-build.py b/.github/scripts/check-learn-build.py new file mode 100644 index 00000000..b8b06eba --- /dev/null +++ b/.github/scripts/check-learn-build.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Check Learn Build bot comments on a PR and decide if auto-merge is safe. + +This script: +1. Fetches all comments from the PR +2. Finds the latest PoliCheck and Build Report comments from learn-build-service-prod +3. Validates PoliCheck shows "No issues found" +4. Fetches the full build report, extracts the JSON build log URL, and parses warnings +5. Compares warnings against the known-warnings.txt baseline +6. Exits 0 if safe to merge, 1 if not + +Environment variables: + GH_TOKEN - GitHub token for API access + PR_NUMBER - Pull request number + GITHUB_OUTPUT - GitHub Actions output file +""" + +import json +import os +import re +import subprocess +import sys +import urllib.request + + +def gh(*args): + """Run a gh CLI command and return stdout.""" + result = subprocess.run( + ["gh"] + list(args), + capture_output=True, text=True, check=True, + ) + return result.stdout.strip() + + +def get_pr_comments(pr_number): + """Get all comments on a PR, sorted by creation time.""" + raw = gh( + "pr", "view", str(pr_number), + "--json", "comments,headRefName,headRefOid", + ) + return json.loads(raw) + + +def find_latest_bot_comments(comments, head_sha): + """Find the latest PoliCheck and Build Report comments for the head commit. + + The Build Report comment contains the commit SHA, so we can match it. + PoliCheck comments don't contain the SHA, so we take the latest one + that was posted BEFORE or at the same time as the matching Build Report. + """ + policheck = None + build_report = None + + for comment in comments: + if comment["author"]["login"] != "learn-build-service-prod": + continue + + body = comment["body"] + + if "PoliCheck Scan Report" in body: + policheck = comment + + if "Validation status:" in body and head_sha[:7] in body: + build_report = comment + + return policheck, build_report + + +def check_policheck(comment): + """Check if PoliCheck report shows no issues. Returns (ok, message).""" + body = comment["body"] + if ":white_check_mark: No issues found" in body: + return True, "PoliCheck: No issues found" + if ":white_check_mark:" in body and "No issues found" in body: + return True, "PoliCheck: No issues found" + return False, "PoliCheck: Issues found - manual review required" + + +def extract_build_report_url(comment): + """Extract the full build report URL from the Build Report comment.""" + body = comment["body"] + match = re.search( + r'\[build report\]\((https://buildapi\.docs\.microsoft\.com/[^)]+)\)', + body, re.IGNORECASE, + ) + if match: + return match.group(1) + return None + + +def check_build_status(comment): + """Check if the build has errors. Returns (has_errors, status_text).""" + body = comment["body"] + if ":x:" in body and "errors" in body.lower(): + return True, "Build has errors" + return False, "No build errors" + + +def extract_build_log_url(report_url): + """Fetch the build report HTML and extract the JSON build log URL. + + The build report page has a `build_log_url` attribute on the #Summary element + that points to a JSON endpoint with structured warning data. + """ + req = urllib.request.Request(report_url) + with urllib.request.urlopen(req, timeout=30) as response: + content = response.read().decode("utf-8") + + match = re.search(r'build_log_url="([^"]+)"', content) + if match: + return match.group(1), None + return None, "Could not find build_log_url in build report HTML" + + +def fetch_build_log_warnings(build_log_url): + """Fetch the JSON build log and extract all warnings. + + The JSON has a `build_log_error_items` array with structured entries + containing file, code, message, and severity fields. + """ + req = urllib.request.Request(build_log_url) + with urllib.request.urlopen(req, timeout=30) as response: + raw = response.read().decode("utf-8-sig") + data = json.loads(raw) + + items = data.get("build_log_error_items", []) + warnings = [] + errors = [] + + for item in items: + severity = item.get("message_severity", -1) + file_path = item.get("file", "") + code = item.get("code", "") + message = item.get("message", "") + entry = f"{file_path}|{code}|{message}" + + if severity == 0: # Error + errors.append(entry) + elif severity == 1: # Warning + warnings.append(entry) + + warnings.sort() + errors.sort() + return warnings, errors + + +def load_baseline(path): + """Load the known-warnings.txt baseline file.""" + if not os.path.exists(path): + return None + with open(path) as f: + return sorted(line.strip() for line in f if line.strip()) + + +def compare_warnings(current, baseline): + """Compare current warnings against baseline. + + Returns (ok, new_warnings, removed_warnings). + - ok is True if there are no NEW warnings (removals are fine). + """ + current_set = set(current) + baseline_set = set(baseline) + + new_warnings = sorted(current_set - baseline_set) + removed_warnings = sorted(baseline_set - current_set) + + return len(new_warnings) == 0, new_warnings, removed_warnings + + +def set_output(name, value): + """Set a GitHub Actions output variable.""" + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{name}={value}\n") + + +def main(): + pr_number = os.environ.get("PR_NUMBER") + if not pr_number: + print("ERROR: PR_NUMBER environment variable not set") + sys.exit(1) + + baseline_path = os.path.join( + os.environ.get("GITHUB_WORKSPACE", "."), + ".github", "known-warnings.txt", + ) + + # Get PR info and comments + print(f"Checking PR #{pr_number}...") + pr_data = get_pr_comments(pr_number) + head_ref = pr_data["headRefName"] + head_sha = pr_data["headRefOid"] + comments = pr_data["comments"] + + print(f" Branch: {head_ref}") + print(f" Head SHA: {head_sha[:12]}") + print(f" Total comments: {len(comments)}") + + # Only auto-merge the automation branch + if head_ref != "automation/update-api-docs": + print(f" Skipping: not the automation branch (got {head_ref})") + set_output("should_merge", "false") + set_output("reason", "Not the automation branch") + sys.exit(0) + + # Find latest bot comments for the head commit + policheck, build_report = find_latest_bot_comments(comments, head_sha) + + if not policheck: + print(" Waiting: no PoliCheck comment found yet") + set_output("should_merge", "false") + set_output("reason", "Waiting for PoliCheck comment") + sys.exit(0) + + if not build_report: + print(" Waiting: no Build Report comment found for head commit") + set_output("should_merge", "false") + set_output("reason", "Waiting for Build Report comment") + sys.exit(0) + + print(" Found both PoliCheck and Build Report comments") + + # Check PoliCheck + poli_ok, poli_msg = check_policheck(policheck) + print(f" {poli_msg}") + if not poli_ok: + set_output("should_merge", "false") + set_output("reason", poli_msg) + sys.exit(1) + + # Check for build errors + has_errors, error_msg = check_build_status(build_report) + print(f" {error_msg}") + if has_errors: + set_output("should_merge", "false") + set_output("reason", "Build has errors - manual review required") + sys.exit(1) + + # Extract and fetch the full build report + report_url = extract_build_report_url(build_report) + if not report_url: + print(" WARNING: Could not find build report URL in comment") + set_output("should_merge", "false") + set_output("reason", "Could not find build report URL") + sys.exit(1) + + print(" Fetching build report to find JSON log URL...") + build_log_url, log_err = extract_build_log_url(report_url) + if log_err: + print(f" ERROR: {log_err}") + set_output("should_merge", "false") + set_output("reason", f"Failed to extract build log URL: {log_err}") + sys.exit(1) + + print(" Fetching structured build log (JSON)...") + current_warnings, current_errors = fetch_build_log_warnings(build_log_url) + + if current_errors: + print(f" ❌ {len(current_errors)} build error(s) found:") + for e in current_errors: + print(f" ! {e}") + set_output("should_merge", "false") + set_output("reason", f"{len(current_errors)} build error(s) found") + sys.exit(1) + + print(f" Found {len(current_warnings)} warnings in build log") + + # Load and compare baseline + baseline = load_baseline(baseline_path) + if baseline is None: + print(" WARNING: No baseline file found at .github/known-warnings.txt") + print(" Cannot compare warnings - manual review required") + set_output("should_merge", "false") + set_output("reason", "No baseline file found") + sys.exit(1) + + print(f" Baseline has {len(baseline)} known warnings") + + ok, new_warnings, removed_warnings = compare_warnings( + current_warnings, baseline, + ) + + if removed_warnings: + print(f" {len(removed_warnings)} warning(s) were resolved (good!):") + for w in removed_warnings[:5]: + print(f" - {w}") + if len(removed_warnings) > 5: + print(f" ... and {len(removed_warnings) - 5} more") + + if new_warnings: + print(f" {len(new_warnings)} NEW warning(s) found:") + for w in new_warnings: + print(f" + {w}") + + summary = f"{len(new_warnings)} new warning(s) found" + set_output("should_merge", "false") + set_output("reason", summary) + set_output("new_warnings", json.dumps(new_warnings)) + sys.exit(1) + + print(" ✅ All checks passed - safe to merge!") + set_output("should_merge", "true") + set_output("reason", "All checks passed") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/automerge-docs.yml b/.github/workflows/automerge-docs.yml new file mode 100644 index 00000000..c6e150a7 --- /dev/null +++ b/.github/workflows/automerge-docs.yml @@ -0,0 +1,49 @@ +name: Auto-merge docs PR + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + issues: read + +jobs: + check-and-merge: + # Only run on PR comments from the Learn Build bot + if: | + github.event.issue.pull_request && + github.event.comment.user.login == 'learn-build-service-prod' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check Learn Build comments + id: check + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + run: python3 .github/scripts/check-learn-build.py + + - name: Merge PR + if: steps.check.outputs.should_merge == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "Auto-merging PR #${{ github.event.issue.number }}..." + gh pr merge ${{ github.event.issue.number }} --squash \ + --subject "Update API docs from latest CI build" \ + --body "Auto-merged after Learn Build validation passed (PoliCheck clean, no new warnings)." + + - name: Post comment on new warnings + if: failure() && steps.check.outputs.new_warnings != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr comment ${{ github.event.issue.number }} --body \ + "⚠️ **Auto-merge blocked**: ${{ steps.check.outputs.reason }} + + New warnings need manual review before merging. Update \`.github/known-warnings.txt\` if these warnings are expected." From 2687e37ee0987b88fcb4ffc36d5e83609b6f90c2 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 16:48:28 +0200 Subject: [PATCH 02/14] Fix warning comparison to use multiset and support write-api-docs branch - compare_warnings now uses Counter (multiset) instead of set, so duplicate warnings with the same text are tracked individually. Previously 'Gdk.RGBA' appearing 8 times vs 2 in baseline was reported as 0 new (set dedup), now correctly reports 6 new. - Add automation/write-api-docs to the list of automation branches eligible for auto-merge checking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/check-learn-build.py | 37 +++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/.github/scripts/check-learn-build.py b/.github/scripts/check-learn-build.py index b8b06eba..1bb3d1f5 100644 --- a/.github/scripts/check-learn-build.py +++ b/.github/scripts/check-learn-build.py @@ -153,16 +153,31 @@ def load_baseline(path): def compare_warnings(current, baseline): - """Compare current warnings against baseline. + """Compare current warnings against baseline using multiset comparison. + + Duplicate warnings (same file/code/message appearing multiple times) are + tracked individually. If the baseline has 2 occurrences of a warning and + the current build has 4, that counts as 2 new warnings. Returns (ok, new_warnings, removed_warnings). - ok is True if there are no NEW warnings (removals are fine). """ - current_set = set(current) - baseline_set = set(baseline) + from collections import Counter + + current_counts = Counter(current) + baseline_counts = Counter(baseline) + + new_warnings = [] + for entry, count in sorted(current_counts.items()): + extra = count - baseline_counts.get(entry, 0) + for _ in range(extra): + new_warnings.append(entry) - new_warnings = sorted(current_set - baseline_set) - removed_warnings = sorted(baseline_set - current_set) + removed_warnings = [] + for entry, count in sorted(baseline_counts.items()): + missing = count - current_counts.get(entry, 0) + for _ in range(missing): + removed_warnings.append(entry) return len(new_warnings) == 0, new_warnings, removed_warnings @@ -197,11 +212,15 @@ def main(): print(f" Head SHA: {head_sha[:12]}") print(f" Total comments: {len(comments)}") - # Only auto-merge the automation branch - if head_ref != "automation/update-api-docs": - print(f" Skipping: not the automation branch (got {head_ref})") + # Only auto-merge automation branches + automation_branches = [ + "automation/update-api-docs", + "automation/write-api-docs", + ] + if head_ref not in automation_branches: + print(f" Skipping: not an automation branch (got {head_ref})") set_output("should_merge", "false") - set_output("reason", "Not the automation branch") + set_output("reason", "Not an automation branch") sys.exit(0) # Find latest bot comments for the head commit From 019b553a9c29264c69d8f093968f5910e520b38a Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 16:53:32 +0200 Subject: [PATCH 03/14] Verify all GitHub status checks are green before merging Check commit statuses (SUCCESS/ERROR/PENDING) and check runs (COMPLETED+SUCCESS) before proceeding with comment analysis. Blocks merge if any check is not green. Tested against: - PR 70: all green -> passes - PR 75: OpenPublishing.Build ERROR -> blocks - PR 81: all green but new warnings -> blocks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/check-learn-build.py | 54 +++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/.github/scripts/check-learn-build.py b/.github/scripts/check-learn-build.py index 1bb3d1f5..7f9a5c79 100644 --- a/.github/scripts/check-learn-build.py +++ b/.github/scripts/check-learn-build.py @@ -36,7 +36,7 @@ def get_pr_comments(pr_number): """Get all comments on a PR, sorted by creation time.""" raw = gh( "pr", "view", str(pr_number), - "--json", "comments,headRefName,headRefOid", + "--json", "comments,headRefName,headRefOid,statusCheckRollup", ) return json.loads(raw) @@ -152,6 +152,38 @@ def load_baseline(path): return sorted(line.strip() for line in f if line.strip()) +def check_github_statuses(checks): + """Verify all GitHub commit statuses and check runs are green. + + Returns (ok, failures) where failures is a list of (name, state) tuples. + Commit statuses use 'state' (SUCCESS/ERROR/PENDING/FAILURE). + Check runs use 'status' + 'conclusion' (COMPLETED+SUCCESS, etc.). + """ + failures = [] + for check in checks: + name = check.get("context") or check.get("name") or "unknown" + # Commit status API + state = check.get("state", "") + # Check runs API + status = check.get("status", "") + conclusion = check.get("conclusion", "") + + if state in ("SUCCESS",): + continue + if status == "COMPLETED" and conclusion == "SUCCESS": + continue + if state == "PENDING" or status in ("IN_PROGRESS", "QUEUED"): + failures.append((name, "PENDING")) + elif state in ("ERROR", "FAILURE"): + failures.append((name, state)) + elif status == "COMPLETED" and conclusion not in ("SUCCESS", "NEUTRAL", "SKIPPED"): + failures.append((name, conclusion or status)) + elif state or status: + failures.append((name, state or f"{status}/{conclusion}")) + + return len(failures) == 0, failures + + def compare_warnings(current, baseline): """Compare current warnings against baseline using multiset comparison. @@ -223,6 +255,26 @@ def main(): set_output("reason", "Not an automation branch") sys.exit(0) + # Check all GitHub commit statuses and check runs are green + checks = pr_data.get("statusCheckRollup", []) + print(f" Status checks: {len(checks)}") + statuses_ok, failures = check_github_statuses(checks) + for check in checks: + name = check.get("context") or check.get("name") or "unknown" + state = check.get("state") or check.get("status", "") + conclusion = check.get("conclusion", "") + display = state if state else f"{conclusion}" + print(f" {name}: {display}") + + if not statuses_ok: + names = ", ".join(f"{n} ({s})" for n, s in failures) + print(f" ❌ Not all checks are green: {names}") + set_output("should_merge", "false") + set_output("reason", f"Checks not green: {names}") + sys.exit(1) + + print(" All status checks are green") + # Find latest bot comments for the head commit policheck, build_report = find_latest_bot_comments(comments, head_sha) From 257830d57f77f2cc1f0e0c908efdc51e2ed14813 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 17:27:47 +0200 Subject: [PATCH 04/14] Switch known-warnings baseline from txt to CSV with count column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse duplicate warnings (120 lines -> 63 unique rows with counts). Format: file,code,message,count — easier to read and edit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/known-warnings.csv | 64 ++++++++++++++ .github/known-warnings.txt | 120 --------------------------- .github/scripts/check-learn-build.py | 34 +++++--- .github/workflows/automerge-docs.yml | 2 +- 4 files changed, 87 insertions(+), 133 deletions(-) create mode 100644 .github/known-warnings.csv delete mode 100644 .github/known-warnings.txt diff --git a/.github/known-warnings.csv b/.github/known-warnings.csv new file mode 100644 index 00000000..a15c0d22 --- /dev/null +++ b/.github/known-warnings.csv @@ -0,0 +1,64 @@ +file,code,message,count +SkiaSharpAPI/SkiaSharp.Views.Desktop/SKGLControl.xml,xref-not-found,Cross reference not found: 'OpenTK.GLControl'.,1 +SkiaSharpAPI/SkiaSharp.Views.Desktop/SKGLControl.xml,xref-not-found,Cross reference not found: 'OpenTK.GLControl.GLControl'.,1 +SkiaSharpAPI/SkiaSharp.Views.Desktop/SKGLControl.xml,xref-not-found,Cross reference not found: 'OpenTK.GLControl.GLControlSettings'.,1 +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml,xref-not-found,Cross reference not found: 'Gdk.Color'.,4 +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml,xref-not-found,Cross reference not found: 'Gdk.Pixbuf'.,14 +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml,xref-not-found,Cross reference not found: 'Gdk.Point'.,4 +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml,xref-not-found,Cross reference not found: 'Gdk.RGBA'.,2 +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml,xref-not-found,Cross reference not found: 'Gdk.Rectangle'.,4 +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml,xref-not-found,Cross reference not found: 'Gdk.Size'.,4 +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml,xref-not-found,Cross reference not found: 'Graphene.Point'.,1 +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml,xref-not-found,Cross reference not found: 'Graphene.Point3D'.,1 +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml,xref-not-found,Cross reference not found: 'Graphene.Rect'.,1 +SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml,xref-not-found,Cross reference not found: 'Graphene.Size'.,1 +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKDrawingArea.xml,xref-not-found,Cross reference not found: 'Cairo.Context'.,3 +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKDrawingArea.xml,xref-not-found,Cross reference not found: 'Gtk.DrawingArea'.,1 +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKWidget.xml,xref-not-found,Cross reference not found: 'Gdk.EventExpose'.,2 +SkiaSharpAPI/SkiaSharp.Views.Gtk/SKWidget.xml,xref-not-found,Cross reference not found: 'Gtk.DrawingArea'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen.NUI/CustomRenderingView.xml,xref-not-found,Cross reference not found: 'Tizen.NUI.BaseComponents.ImageView'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen.NUI/SKCanvasView.xml,xref-not-found,Cross reference not found: 'Tizen.NUI.BaseComponents.ImageView'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen.NUI/SKGLSurfaceView.xml,xref-not-found,Cross reference not found: 'Tizen.NUI.BaseComponents.ImageView'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml,xref-not-found,Cross reference not found: 'ElmSharp.EvasObject'.,3 +SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml,xref-not-found,Cross reference not found: 'ElmSharp.Rect'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml,xref-not-found,Cross reference not found: 'ElmSharp.Widget'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKCanvasView.xml,xref-not-found,Cross reference not found: 'ElmSharp.EvasObject'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKCanvasView.xml,xref-not-found,Cross reference not found: 'ElmSharp.Rect'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKCanvasView.xml,xref-not-found,Cross reference not found: 'ElmSharp.Widget'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml,xref-not-found,Cross reference not found: 'ElmSharp.EvasObject'.,2 +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml,xref-not-found,Cross reference not found: 'ElmSharp.Rect'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml,xref-not-found,Cross reference not found: 'ElmSharp.Widget'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml,xref-not-found,Cross reference not found: 'ElmSharp.Color'.,2 +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml,xref-not-found,Cross reference not found: 'ElmSharp.Point'.,4 +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml,xref-not-found,Cross reference not found: 'ElmSharp.Rect'.,4 +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml,xref-not-found,Cross reference not found: 'ElmSharp.Size'.,4 +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml,xref-not-found,Cross reference not found: 'Tizen.NUI.Color'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml,xref-not-found,Cross reference not found: 'Tizen.NUI.Position'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml,xref-not-found,Cross reference not found: 'Tizen.NUI.Position2D'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml,xref-not-found,Cross reference not found: 'Tizen.NUI.Rectangle'.,2 +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml,xref-not-found,Cross reference not found: 'Tizen.NUI.Size'.,1 +SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml,xref-not-found,Cross reference not found: 'Tizen.NUI.Size2D'.,1 +SkiaSharpAPI/SkiaSharp.Views.UWP/AngleSwapChainPanel.xml,xref-not-found,Cross reference not found: 'Windows.UI.Xaml.Controls.SwapChainPanel'.,1 +SkiaSharpAPI/SkiaSharp.Views.UWP/SKSwapChainPanel.xml,xref-not-found,Cross reference not found: 'Windows.UI.Xaml.Controls.SwapChainPanel'.,1 +SkiaSharpAPI/SkiaSharp.Views.UWP/SKXamlCanvas.xml,xref-not-found,Cross reference not found: 'Windows.UI.Xaml.Controls.Canvas'.,1 +SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml,xref-not-found,Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'.,7 +SkiaSharpAPI/SkiaSharp.Views.WPF/SKGLElement.xml,xref-not-found,Cross reference not found: 'OpenTK.Wpf.GLWpfControl'.,1 +SkiaSharpAPI/SkiaSharp.Views.Windows/AngleSwapChainPanel.xml,xref-not-found,Cross reference not found: 'Microsoft.UI.Xaml.Controls.SwapChainPanel'.,1 +SkiaSharpAPI/SkiaSharp.Views.Windows/SKSwapChainPanel.xml,xref-not-found,Cross reference not found: 'Microsoft.UI.Xaml.Controls.SwapChainPanel'.,1 +SkiaSharpAPI/SkiaSharp.Views.Windows/SKXamlCanvas.xml,xref-not-found,Cross reference not found: 'Microsoft.UI.Xaml.Controls.Canvas'.,1 +SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml,xref-not-found,Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'.,7 +SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml,xref-not-found,Cross reference not found: 'SharpVk.Device'.,1 +SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml,xref-not-found,Cross reference not found: 'SharpVk.Instance'.,1 +SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml,xref-not-found,Cross reference not found: 'SharpVk.PhysicalDevice'.,1 +SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml,xref-not-found,Cross reference not found: 'SharpVk.PhysicalDeviceFeatures'.,1 +SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml,xref-not-found,Cross reference not found: 'SharpVk.Queue'.,1 +SkiaSharpAPI/SkiaSharp/GRSharpVkGetProcedureAddressDelegate.xml,xref-not-found,Cross reference not found: 'SharpVk.Device'.,1 +SkiaSharpAPI/SkiaSharp/GRSharpVkGetProcedureAddressDelegate.xml,xref-not-found,Cross reference not found: 'SharpVk.Instance'.,1 +SkiaSharpAPI/SkiaSharp/GRVkExtensionsSharpVkExtensions.xml,xref-not-found,Cross reference not found: 'SharpVk.Instance'.,2 +SkiaSharpAPI/SkiaSharp/GRVkExtensionsSharpVkExtensions.xml,xref-not-found,Cross reference not found: 'SharpVk.PhysicalDevice'.,2 +SkiaSharpAPI/SkiaSharp/GRVorticeD3DBackendContext.xml,xref-not-found,Cross reference not found: 'Vortice.DXGI.IDXGIAdapter1'.,1 +SkiaSharpAPI/SkiaSharp/GRVorticeD3DBackendContext.xml,xref-not-found,Cross reference not found: 'Vortice.Direct3D12.ID3D12CommandQueue'.,1 +SkiaSharpAPI/SkiaSharp/GRVorticeD3DBackendContext.xml,xref-not-found,Cross reference not found: 'Vortice.Direct3D12.ID3D12Device2'.,1 +SkiaSharpAPI/SkiaSharp/GRVorticeD3DTextureResourceInfo.xml,xref-not-found,Cross reference not found: 'Vortice.DXGI.Format'.,1 +SkiaSharpAPI/SkiaSharp/GRVorticeD3DTextureResourceInfo.xml,xref-not-found,Cross reference not found: 'Vortice.Direct3D12.ID3D12Resource'.,1 +SkiaSharpAPI/SkiaSharp/GRVorticeD3DTextureResourceInfo.xml,xref-not-found,Cross reference not found: 'Vortice.Direct3D12.ResourceStates'.,1 diff --git a/.github/known-warnings.txt b/.github/known-warnings.txt deleted file mode 100644 index 821f80f4..00000000 --- a/.github/known-warnings.txt +++ /dev/null @@ -1,120 +0,0 @@ -SkiaSharpAPI/SkiaSharp.Views.Desktop/SKGLControl.xml|xref-not-found|Cross reference not found: 'OpenTK.GLControl'. -SkiaSharpAPI/SkiaSharp.Views.Desktop/SKGLControl.xml|xref-not-found|Cross reference not found: 'OpenTK.GLControl.GLControl'. -SkiaSharpAPI/SkiaSharp.Views.Desktop/SKGLControl.xml|xref-not-found|Cross reference not found: 'OpenTK.GLControl.GLControlSettings'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Color'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Color'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Color'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Color'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Pixbuf'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Point'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Point'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Point'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Point'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.RGBA'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.RGBA'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Rectangle'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Rectangle'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Rectangle'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Rectangle'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Size'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Size'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Size'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Gdk.Size'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Graphene.Point'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Graphene.Point3D'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Graphene.Rect'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/GTKExtensions.xml|xref-not-found|Cross reference not found: 'Graphene.Size'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/SKDrawingArea.xml|xref-not-found|Cross reference not found: 'Cairo.Context'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/SKDrawingArea.xml|xref-not-found|Cross reference not found: 'Cairo.Context'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/SKDrawingArea.xml|xref-not-found|Cross reference not found: 'Cairo.Context'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/SKDrawingArea.xml|xref-not-found|Cross reference not found: 'Gtk.DrawingArea'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/SKWidget.xml|xref-not-found|Cross reference not found: 'Gdk.EventExpose'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/SKWidget.xml|xref-not-found|Cross reference not found: 'Gdk.EventExpose'. -SkiaSharpAPI/SkiaSharp.Views.Gtk/SKWidget.xml|xref-not-found|Cross reference not found: 'Gtk.DrawingArea'. -SkiaSharpAPI/SkiaSharp.Views.Tizen.NUI/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.BaseComponents.ImageView'. -SkiaSharpAPI/SkiaSharp.Views.Tizen.NUI/SKCanvasView.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.BaseComponents.ImageView'. -SkiaSharpAPI/SkiaSharp.Views.Tizen.NUI/SKGLSurfaceView.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.BaseComponents.ImageView'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/CustomRenderingView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Widget'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/SKCanvasView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/SKCanvasView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/SKCanvasView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Widget'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml|xref-not-found|Cross reference not found: 'ElmSharp.EvasObject'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/SKGLSurfaceView.xml|xref-not-found|Cross reference not found: 'ElmSharp.Widget'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Color'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Color'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Point'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Point'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Point'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Point'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Rect'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Size'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Size'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Size'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'ElmSharp.Size'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Color'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Position'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Position2D'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Rectangle'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Rectangle'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Size'. -SkiaSharpAPI/SkiaSharp.Views.Tizen/TizenExtensions.xml|xref-not-found|Cross reference not found: 'Tizen.NUI.Size2D'. -SkiaSharpAPI/SkiaSharp.Views.UWP/AngleSwapChainPanel.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Controls.SwapChainPanel'. -SkiaSharpAPI/SkiaSharp.Views.UWP/SKSwapChainPanel.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Controls.SwapChainPanel'. -SkiaSharpAPI/SkiaSharp.Views.UWP/SKXamlCanvas.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Controls.Canvas'. -SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.UWP/UWPExtensions.xml|xref-not-found|Cross reference not found: 'Windows.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.WPF/SKGLElement.xml|xref-not-found|Cross reference not found: 'OpenTK.Wpf.GLWpfControl'. -SkiaSharpAPI/SkiaSharp.Views.Windows/AngleSwapChainPanel.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Controls.SwapChainPanel'. -SkiaSharpAPI/SkiaSharp.Views.Windows/SKSwapChainPanel.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Controls.SwapChainPanel'. -SkiaSharpAPI/SkiaSharp.Views.Windows/SKXamlCanvas.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Controls.Canvas'. -SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp.Views.Windows/WindowsExtensions.xml|xref-not-found|Cross reference not found: 'Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap'. -SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml|xref-not-found|Cross reference not found: 'SharpVk.Device'. -SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml|xref-not-found|Cross reference not found: 'SharpVk.Instance'. -SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml|xref-not-found|Cross reference not found: 'SharpVk.PhysicalDevice'. -SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml|xref-not-found|Cross reference not found: 'SharpVk.PhysicalDeviceFeatures'. -SkiaSharpAPI/SkiaSharp/GRSharpVkBackendContext.xml|xref-not-found|Cross reference not found: 'SharpVk.Queue'. -SkiaSharpAPI/SkiaSharp/GRSharpVkGetProcedureAddressDelegate.xml|xref-not-found|Cross reference not found: 'SharpVk.Device'. -SkiaSharpAPI/SkiaSharp/GRSharpVkGetProcedureAddressDelegate.xml|xref-not-found|Cross reference not found: 'SharpVk.Instance'. -SkiaSharpAPI/SkiaSharp/GRVkExtensionsSharpVkExtensions.xml|xref-not-found|Cross reference not found: 'SharpVk.Instance'. -SkiaSharpAPI/SkiaSharp/GRVkExtensionsSharpVkExtensions.xml|xref-not-found|Cross reference not found: 'SharpVk.Instance'. -SkiaSharpAPI/SkiaSharp/GRVkExtensionsSharpVkExtensions.xml|xref-not-found|Cross reference not found: 'SharpVk.PhysicalDevice'. -SkiaSharpAPI/SkiaSharp/GRVkExtensionsSharpVkExtensions.xml|xref-not-found|Cross reference not found: 'SharpVk.PhysicalDevice'. -SkiaSharpAPI/SkiaSharp/GRVorticeD3DBackendContext.xml|xref-not-found|Cross reference not found: 'Vortice.DXGI.IDXGIAdapter1'. -SkiaSharpAPI/SkiaSharp/GRVorticeD3DBackendContext.xml|xref-not-found|Cross reference not found: 'Vortice.Direct3D12.ID3D12CommandQueue'. -SkiaSharpAPI/SkiaSharp/GRVorticeD3DBackendContext.xml|xref-not-found|Cross reference not found: 'Vortice.Direct3D12.ID3D12Device2'. -SkiaSharpAPI/SkiaSharp/GRVorticeD3DTextureResourceInfo.xml|xref-not-found|Cross reference not found: 'Vortice.DXGI.Format'. -SkiaSharpAPI/SkiaSharp/GRVorticeD3DTextureResourceInfo.xml|xref-not-found|Cross reference not found: 'Vortice.Direct3D12.ID3D12Resource'. -SkiaSharpAPI/SkiaSharp/GRVorticeD3DTextureResourceInfo.xml|xref-not-found|Cross reference not found: 'Vortice.Direct3D12.ResourceStates'. diff --git a/.github/scripts/check-learn-build.py b/.github/scripts/check-learn-build.py index 7f9a5c79..aff7668c 100644 --- a/.github/scripts/check-learn-build.py +++ b/.github/scripts/check-learn-build.py @@ -15,6 +15,7 @@ GITHUB_OUTPUT - GitHub Actions output file """ +import csv import json import os import re @@ -145,11 +146,21 @@ def fetch_build_log_warnings(build_log_url): def load_baseline(path): - """Load the known-warnings.txt baseline file.""" + """Load the known-warnings.csv baseline file. + + Returns a Counter mapping 'file|code|message' -> count. + """ + from collections import Counter + if not os.path.exists(path): return None - with open(path) as f: - return sorted(line.strip() for line in f if line.strip()) + baseline = Counter() + with open(path, newline="") as f: + reader = csv.DictReader(f) + for row in reader: + entry = f"{row['file']}|{row['code']}|{row['message']}" + baseline[entry] = int(row["count"]) + return baseline def check_github_statuses(checks): @@ -187,9 +198,8 @@ def check_github_statuses(checks): def compare_warnings(current, baseline): """Compare current warnings against baseline using multiset comparison. - Duplicate warnings (same file/code/message appearing multiple times) are - tracked individually. If the baseline has 2 occurrences of a warning and - the current build has 4, that counts as 2 new warnings. + current is a sorted list of 'file|code|message' strings (may have dupes). + baseline is a Counter mapping 'file|code|message' -> expected count. Returns (ok, new_warnings, removed_warnings). - ok is True if there are no NEW warnings (removals are fine). @@ -197,16 +207,15 @@ def compare_warnings(current, baseline): from collections import Counter current_counts = Counter(current) - baseline_counts = Counter(baseline) new_warnings = [] for entry, count in sorted(current_counts.items()): - extra = count - baseline_counts.get(entry, 0) + extra = count - baseline.get(entry, 0) for _ in range(extra): new_warnings.append(entry) removed_warnings = [] - for entry, count in sorted(baseline_counts.items()): + for entry, count in sorted(baseline.items()): missing = count - current_counts.get(entry, 0) for _ in range(missing): removed_warnings.append(entry) @@ -230,7 +239,7 @@ def main(): baseline_path = os.path.join( os.environ.get("GITHUB_WORKSPACE", "."), - ".github", "known-warnings.txt", + ".github", "known-warnings.csv", ) # Get PR info and comments @@ -340,13 +349,14 @@ def main(): # Load and compare baseline baseline = load_baseline(baseline_path) if baseline is None: - print(" WARNING: No baseline file found at .github/known-warnings.txt") + print(" WARNING: No baseline file found at .github/known-warnings.csv") print(" Cannot compare warnings - manual review required") set_output("should_merge", "false") set_output("reason", "No baseline file found") sys.exit(1) - print(f" Baseline has {len(baseline)} known warnings") + baseline_total = sum(baseline.values()) + print(f" Baseline has {len(baseline)} unique warnings ({baseline_total} total)") ok, new_warnings, removed_warnings = compare_warnings( current_warnings, baseline, diff --git a/.github/workflows/automerge-docs.yml b/.github/workflows/automerge-docs.yml index c6e150a7..1ed3e987 100644 --- a/.github/workflows/automerge-docs.yml +++ b/.github/workflows/automerge-docs.yml @@ -46,4 +46,4 @@ jobs: gh pr comment ${{ github.event.issue.number }} --body \ "⚠️ **Auto-merge blocked**: ${{ steps.check.outputs.reason }} - New warnings need manual review before merging. Update \`.github/known-warnings.txt\` if these warnings are expected." + New warnings need manual review before merging. Update \`.github/known-warnings.csv\` if these warnings are expected." From 71680296cb77bfc03ad9a98c7ba5f516c7726092 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 17:29:50 +0200 Subject: [PATCH 05/14] =?UTF-8?q?Simplify=20PoliCheck=20check=20=E2=80=94?= =?UTF-8?q?=20remove=20redundant=20condition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/check-learn-build.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/scripts/check-learn-build.py b/.github/scripts/check-learn-build.py index aff7668c..a22bc160 100644 --- a/.github/scripts/check-learn-build.py +++ b/.github/scripts/check-learn-build.py @@ -70,9 +70,7 @@ def find_latest_bot_comments(comments, head_sha): def check_policheck(comment): """Check if PoliCheck report shows no issues. Returns (ok, message).""" body = comment["body"] - if ":white_check_mark: No issues found" in body: - return True, "PoliCheck: No issues found" - if ":white_check_mark:" in body and "No issues found" in body: + if "No issues found" in body: return True, "PoliCheck: No issues found" return False, "PoliCheck: Issues found - manual review required" From 75cb67644fff11335a254f2ea2f15517668825fa Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 17:34:32 +0200 Subject: [PATCH 06/14] Rewrite to use status targetUrls instead of comment parsing Replace comment-based approach with status-driven design: - Read build report URLs directly from GitHub status targetUrls (OpenPublishing.Build and PoliCheck Scan statuses) - No more parsing PR comments or matching commit SHAs in markdown - Trigger workflow on 'status' event instead of 'issue_comment' - First step finds the PR for the commit SHA, then runs the check The PoliCheck pass/fail is now determined by its status state (SUCCESS vs ERROR), and the build warnings are fetched from the OpenPublishing.Build targetUrl -> build report -> JSON build log. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/check-learn-build.py | 242 +++++++++------------------ .github/workflows/automerge-docs.yml | 44 +++-- 2 files changed, 112 insertions(+), 174 deletions(-) diff --git a/.github/scripts/check-learn-build.py b/.github/scripts/check-learn-build.py index a22bc160..ca0af9ca 100644 --- a/.github/scripts/check-learn-build.py +++ b/.github/scripts/check-learn-build.py @@ -1,13 +1,17 @@ #!/usr/bin/env python3 -"""Check Learn Build bot comments on a PR and decide if auto-merge is safe. +"""Check Learn Build PR statuses and decide if auto-merge is safe. -This script: -1. Fetches all comments from the PR -2. Finds the latest PoliCheck and Build Report comments from learn-build-service-prod -3. Validates PoliCheck shows "No issues found" -4. Fetches the full build report, extracts the JSON build log URL, and parses warnings -5. Compares warnings against the known-warnings.txt baseline -6. Exits 0 if safe to merge, 1 if not +This script reads GitHub commit statuses (not PR comments) to determine +if a docs PR can be auto-merged. The two Learn Build statuses each have +a targetUrl pointing to a build report, which links to a JSON build log. + +Flow: +1. Get PR status checks via gh CLI +2. Verify all checks are green (SUCCESS / COMPLETED+SUCCESS) +3. Find the OpenPublishing.Build targetUrl → build report → JSON build log +4. Extract warnings/errors from the JSON log +5. Compare warnings against the known-warnings.csv baseline +6. Exit 0 if safe to merge, 1 if not Environment variables: GH_TOKEN - GitHub token for API access @@ -33,73 +37,53 @@ def gh(*args): return result.stdout.strip() -def get_pr_comments(pr_number): - """Get all comments on a PR, sorted by creation time.""" +def get_pr_info(pr_number): + """Get PR metadata and status checks.""" raw = gh( "pr", "view", str(pr_number), - "--json", "comments,headRefName,headRefOid,statusCheckRollup", + "--json", "headRefName,headRefOid,statusCheckRollup", ) return json.loads(raw) -def find_latest_bot_comments(comments, head_sha): - """Find the latest PoliCheck and Build Report comments for the head commit. +def check_statuses(checks): + """Verify all GitHub commit statuses and check runs are green. - The Build Report comment contains the commit SHA, so we can match it. - PoliCheck comments don't contain the SHA, so we take the latest one - that was posted BEFORE or at the same time as the matching Build Report. + Returns (all_green, failures, status_map). + - failures is a list of (name, state) tuples for non-green checks. + - status_map is a dict of name -> {state, url} for all checks. """ - policheck = None - build_report = None - - for comment in comments: - if comment["author"]["login"] != "learn-build-service-prod": - continue - - body = comment["body"] - - if "PoliCheck Scan Report" in body: - policheck = comment - - if "Validation status:" in body and head_sha[:7] in body: - build_report = comment - - return policheck, build_report - - -def check_policheck(comment): - """Check if PoliCheck report shows no issues. Returns (ok, message).""" - body = comment["body"] - if "No issues found" in body: - return True, "PoliCheck: No issues found" - return False, "PoliCheck: Issues found - manual review required" + failures = [] + status_map = {} + for check in checks: + name = check.get("context") or check.get("name") or "unknown" + state = check.get("state", "") + status = check.get("status", "") + conclusion = check.get("conclusion", "") + url = check.get("targetUrl") or check.get("detailsUrl") or "" -def extract_build_report_url(comment): - """Extract the full build report URL from the Build Report comment.""" - body = comment["body"] - match = re.search( - r'\[build report\]\((https://buildapi\.docs\.microsoft\.com/[^)]+)\)', - body, re.IGNORECASE, - ) - if match: - return match.group(1) - return None + is_green = ( + state == "SUCCESS" + or (status == "COMPLETED" and conclusion == "SUCCESS") + ) + display_state = state or conclusion or status + status_map[name] = {"state": display_state, "url": url} + if not is_green: + if state == "PENDING" or status in ("IN_PROGRESS", "QUEUED"): + failures.append((name, "PENDING")) + else: + failures.append((name, display_state)) -def check_build_status(comment): - """Check if the build has errors. Returns (has_errors, status_text).""" - body = comment["body"] - if ":x:" in body and "errors" in body.lower(): - return True, "Build has errors" - return False, "No build errors" + return len(failures) == 0, failures, status_map def extract_build_log_url(report_url): """Fetch the build report HTML and extract the JSON build log URL. - The build report page has a `build_log_url` attribute on the #Summary element - that points to a JSON endpoint with structured warning data. + The build report page has a `build_log_url` attribute on the #Summary + element that points to a JSON endpoint with structured data. """ req = urllib.request.Request(report_url) with urllib.request.urlopen(req, timeout=30) as response: @@ -107,15 +91,15 @@ def extract_build_log_url(report_url): match = re.search(r'build_log_url="([^"]+)"', content) if match: - return match.group(1), None - return None, "Could not find build_log_url in build report HTML" + return match.group(1) + return None -def fetch_build_log_warnings(build_log_url): - """Fetch the JSON build log and extract all warnings. +def fetch_build_log(build_log_url): + """Fetch the JSON build log and categorize items by severity. - The JSON has a `build_log_error_items` array with structured entries - containing file, code, message, and severity fields. + Returns (warnings, errors) where each is a sorted list of + 'file|code|message' strings. """ req = urllib.request.Request(build_log_url) with urllib.request.urlopen(req, timeout=30) as response: @@ -128,14 +112,14 @@ def fetch_build_log_warnings(build_log_url): for item in items: severity = item.get("message_severity", -1) - file_path = item.get("file", "") - code = item.get("code", "") - message = item.get("message", "") - entry = f"{file_path}|{code}|{message}" - - if severity == 0: # Error + entry = "{file}|{code}|{message}".format( + file=item.get("file", ""), + code=item.get("code", ""), + message=item.get("message", ""), + ) + if severity == 0: errors.append(entry) - elif severity == 1: # Warning + elif severity == 1: warnings.append(entry) warnings.sort() @@ -161,38 +145,6 @@ def load_baseline(path): return baseline -def check_github_statuses(checks): - """Verify all GitHub commit statuses and check runs are green. - - Returns (ok, failures) where failures is a list of (name, state) tuples. - Commit statuses use 'state' (SUCCESS/ERROR/PENDING/FAILURE). - Check runs use 'status' + 'conclusion' (COMPLETED+SUCCESS, etc.). - """ - failures = [] - for check in checks: - name = check.get("context") or check.get("name") or "unknown" - # Commit status API - state = check.get("state", "") - # Check runs API - status = check.get("status", "") - conclusion = check.get("conclusion", "") - - if state in ("SUCCESS",): - continue - if status == "COMPLETED" and conclusion == "SUCCESS": - continue - if state == "PENDING" or status in ("IN_PROGRESS", "QUEUED"): - failures.append((name, "PENDING")) - elif state in ("ERROR", "FAILURE"): - failures.append((name, state)) - elif status == "COMPLETED" and conclusion not in ("SUCCESS", "NEUTRAL", "SKIPPED"): - failures.append((name, conclusion or status)) - elif state or status: - failures.append((name, state or f"{status}/{conclusion}")) - - return len(failures) == 0, failures - - def compare_warnings(current, baseline): """Compare current warnings against baseline using multiset comparison. @@ -240,16 +192,14 @@ def main(): ".github", "known-warnings.csv", ) - # Get PR info and comments + # Get PR info print(f"Checking PR #{pr_number}...") - pr_data = get_pr_comments(pr_number) + pr_data = get_pr_info(pr_number) head_ref = pr_data["headRefName"] head_sha = pr_data["headRefOid"] - comments = pr_data["comments"] print(f" Branch: {head_ref}") print(f" Head SHA: {head_sha[:12]}") - print(f" Total comments: {len(comments)}") # Only auto-merge automation branches automation_branches = [ @@ -262,18 +212,20 @@ def main(): set_output("reason", "Not an automation branch") sys.exit(0) - # Check all GitHub commit statuses and check runs are green + # Check all GitHub statuses are green checks = pr_data.get("statusCheckRollup", []) - print(f" Status checks: {len(checks)}") - statuses_ok, failures = check_github_statuses(checks) - for check in checks: - name = check.get("context") or check.get("name") or "unknown" - state = check.get("state") or check.get("status", "") - conclusion = check.get("conclusion", "") - display = state if state else f"{conclusion}" - print(f" {name}: {display}") + if not checks: + print(" Waiting: no status checks found yet") + set_output("should_merge", "false") + set_output("reason", "No status checks found") + sys.exit(0) + + all_green, failures, status_map = check_statuses(checks) + print(f" Status checks ({len(checks)}):") + for name, info in sorted(status_map.items()): + print(f" {name}: {info['state']}") - if not statuses_ok: + if not all_green: names = ", ".join(f"{n} ({s})" for n, s in failures) print(f" ❌ Not all checks are green: {names}") set_output("should_merge", "false") @@ -282,57 +234,25 @@ def main(): print(" All status checks are green") - # Find latest bot comments for the head commit - policheck, build_report = find_latest_bot_comments(comments, head_sha) - - if not policheck: - print(" Waiting: no PoliCheck comment found yet") - set_output("should_merge", "false") - set_output("reason", "Waiting for PoliCheck comment") - sys.exit(0) - - if not build_report: - print(" Waiting: no Build Report comment found for head commit") - set_output("should_merge", "false") - set_output("reason", "Waiting for Build Report comment") - sys.exit(0) - - print(" Found both PoliCheck and Build Report comments") - - # Check PoliCheck - poli_ok, poli_msg = check_policheck(policheck) - print(f" {poli_msg}") - if not poli_ok: - set_output("should_merge", "false") - set_output("reason", poli_msg) - sys.exit(1) - - # Check for build errors - has_errors, error_msg = check_build_status(build_report) - print(f" {error_msg}") - if has_errors: - set_output("should_merge", "false") - set_output("reason", "Build has errors - manual review required") - sys.exit(1) - - # Extract and fetch the full build report - report_url = extract_build_report_url(build_report) - if not report_url: - print(" WARNING: Could not find build report URL in comment") + # Get the OpenPublishing.Build report URL from the status targetUrl + build_status = status_map.get("OpenPublishing.Build") + if not build_status or not build_status["url"]: + print(" ERROR: No OpenPublishing.Build status with targetUrl found") set_output("should_merge", "false") - set_output("reason", "Could not find build report URL") + set_output("reason", "No OpenPublishing.Build status found") sys.exit(1) - print(" Fetching build report to find JSON log URL...") - build_log_url, log_err = extract_build_log_url(report_url) - if log_err: - print(f" ERROR: {log_err}") + report_url = build_status["url"] + print(f" Fetching build report from status targetUrl...") + build_log_url = extract_build_log_url(report_url) + if not build_log_url: + print(" ERROR: Could not find build_log_url in build report") set_output("should_merge", "false") - set_output("reason", f"Failed to extract build log URL: {log_err}") + set_output("reason", "Could not find build_log_url in report") sys.exit(1) print(" Fetching structured build log (JSON)...") - current_warnings, current_errors = fetch_build_log_warnings(build_log_url) + current_warnings, current_errors = fetch_build_log(build_log_url) if current_errors: print(f" ❌ {len(current_errors)} build error(s) found:") diff --git a/.github/workflows/automerge-docs.yml b/.github/workflows/automerge-docs.yml index 1ed3e987..b3c5c5c5 100644 --- a/.github/workflows/automerge-docs.yml +++ b/.github/workflows/automerge-docs.yml @@ -1,49 +1,67 @@ name: Auto-merge docs PR on: - issue_comment: - types: [created] + status: permissions: contents: write pull-requests: write - issues: read + statuses: read jobs: check-and-merge: - # Only run on PR comments from the Learn Build bot + # Only run when a Learn Build status completes if: | - github.event.issue.pull_request && - github.event.comment.user.login == 'learn-build-service-prod' + github.event.context == 'OpenPublishing.Build' || + github.event.context == 'PoliCheck Scan' runs-on: ubuntu-latest steps: + - name: Find PR for commit + id: pr + env: + GH_TOKEN: ${{ github.token }} + run: | + pr_number=$(gh pr list --repo ${{ github.repository }} \ + --search "${{ github.event.sha }}" --state open \ + --json number,headRefOid \ + --jq '.[] | select(.headRefOid == "${{ github.event.sha }}") | .number') + if [ -z "$pr_number" ]; then + echo "No open PR found for commit ${{ github.event.sha }}" + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "number=$pr_number" >> "$GITHUB_OUTPUT" + echo "found=true" >> "$GITHUB_OUTPUT" + - name: Checkout + if: steps.pr.outputs.found == 'true' uses: actions/checkout@v4 - - name: Check Learn Build comments + - name: Check Learn Build statuses + if: steps.pr.outputs.found == 'true' id: check env: GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.issue.number }} + PR_NUMBER: ${{ steps.pr.outputs.number }} run: python3 .github/scripts/check-learn-build.py - name: Merge PR - if: steps.check.outputs.should_merge == 'true' + if: steps.pr.outputs.found == 'true' && steps.check.outputs.should_merge == 'true' env: GH_TOKEN: ${{ github.token }} run: | - echo "Auto-merging PR #${{ github.event.issue.number }}..." - gh pr merge ${{ github.event.issue.number }} --squash \ + echo "Auto-merging PR #${{ steps.pr.outputs.number }}..." + gh pr merge ${{ steps.pr.outputs.number }} --squash \ --subject "Update API docs from latest CI build" \ - --body "Auto-merged after Learn Build validation passed (PoliCheck clean, no new warnings)." + --body "Auto-merged after Learn Build validation passed (all checks green, no new warnings)." - name: Post comment on new warnings if: failure() && steps.check.outputs.new_warnings != '' env: GH_TOKEN: ${{ github.token }} run: | - gh pr comment ${{ github.event.issue.number }} --body \ + gh pr comment ${{ steps.pr.outputs.number }} --body \ "⚠️ **Auto-merge blocked**: ${{ steps.check.outputs.reason }} New warnings need manual review before merging. Update \`.github/known-warnings.csv\` if these warnings are expected." From ea9572f520cf5b723ec9b6452dc8d63b042dc4e3 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 17:38:10 +0200 Subject: [PATCH 07/14] Move branch check from script to workflow The workflow now filters by automation/* branch prefix in the 'Find PR' step, so the script never runs for non-automation PRs. Script is now purely about validating statuses and warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/check-learn-build.py | 15 +-------------- .github/workflows/automerge-docs.yml | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/.github/scripts/check-learn-build.py b/.github/scripts/check-learn-build.py index ca0af9ca..3a17e751 100644 --- a/.github/scripts/check-learn-build.py +++ b/.github/scripts/check-learn-build.py @@ -41,7 +41,7 @@ def get_pr_info(pr_number): """Get PR metadata and status checks.""" raw = gh( "pr", "view", str(pr_number), - "--json", "headRefName,headRefOid,statusCheckRollup", + "--json", "headRefName,statusCheckRollup", ) return json.loads(raw) @@ -196,21 +196,8 @@ def main(): print(f"Checking PR #{pr_number}...") pr_data = get_pr_info(pr_number) head_ref = pr_data["headRefName"] - head_sha = pr_data["headRefOid"] print(f" Branch: {head_ref}") - print(f" Head SHA: {head_sha[:12]}") - - # Only auto-merge automation branches - automation_branches = [ - "automation/update-api-docs", - "automation/write-api-docs", - ] - if head_ref not in automation_branches: - print(f" Skipping: not an automation branch (got {head_ref})") - set_output("should_merge", "false") - set_output("reason", "Not an automation branch") - sys.exit(0) # Check all GitHub statuses are green checks = pr_data.get("statusCheckRollup", []) diff --git a/.github/workflows/automerge-docs.yml b/.github/workflows/automerge-docs.yml index b3c5c5c5..5f393cb6 100644 --- a/.github/workflows/automerge-docs.yml +++ b/.github/workflows/automerge-docs.yml @@ -22,16 +22,25 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - pr_number=$(gh pr list --repo ${{ github.repository }} \ + pr=$(gh pr list --repo ${{ github.repository }} \ --search "${{ github.event.sha }}" --state open \ - --json number,headRefOid \ - --jq '.[] | select(.headRefOid == "${{ github.event.sha }}") | .number') - if [ -z "$pr_number" ]; then + --json number,headRefOid,headRefName \ + --jq '.[] | select(.headRefOid == "${{ github.event.sha }}")') + if [ -z "$pr" ]; then echo "No open PR found for commit ${{ github.event.sha }}" echo "found=false" >> "$GITHUB_OUTPUT" exit 0 fi + pr_number=$(echo "$pr" | jq -r '.number') + branch=$(echo "$pr" | jq -r '.headRefName') + # Only auto-merge automation branches + if [[ "$branch" != automation/* ]]; then + echo "Skipping: branch '$branch' is not an automation branch" + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi echo "number=$pr_number" >> "$GITHUB_OUTPUT" + echo "branch=$branch" >> "$GITHUB_OUTPUT" echo "found=true" >> "$GITHUB_OUTPUT" - name: Checkout From 52b7af51bc7a37e800d14d681a228d14a309855f Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 17:39:15 +0200 Subject: [PATCH 08/14] Use status event branches for filtering and PR lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status event payload includes: - context: 'OpenPublishing.Build' or 'PoliCheck Scan' - branches[]: array of branch names the commit belongs to - sha: the commit SHA - target_url: direct link to the build report Use branches from the event to: 1. Filter in job 'if' — skip entirely if no automation/ branch 2. Look up PR by branch name (gh pr list --head) instead of SHA search Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/automerge-docs.yml | 33 +++++++++++++++------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/.github/workflows/automerge-docs.yml b/.github/workflows/automerge-docs.yml index 5f393cb6..89e07684 100644 --- a/.github/workflows/automerge-docs.yml +++ b/.github/workflows/automerge-docs.yml @@ -10,10 +10,11 @@ permissions: jobs: check-and-merge: - # Only run when a Learn Build status completes + # Only run when a Learn Build status reports on an automation branch if: | - github.event.context == 'OpenPublishing.Build' || - github.event.context == 'PoliCheck Scan' + (github.event.context == 'OpenPublishing.Build' || + github.event.context == 'PoliCheck Scan') && + contains(toJSON(github.event.branches.*.name), 'automation/') runs-on: ubuntu-latest steps: @@ -22,25 +23,27 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - pr=$(gh pr list --repo ${{ github.repository }} \ - --search "${{ github.event.sha }}" --state open \ - --json number,headRefOid,headRefName \ - --jq '.[] | select(.headRefOid == "${{ github.event.sha }}")') - if [ -z "$pr" ]; then - echo "No open PR found for commit ${{ github.event.sha }}" + # The status event includes the branches this commit belongs to. + # Find the automation branch and look up the open PR for it. + branches='${{ toJSON(github.event.branches.*.name) }}' + branch=$(echo "$branches" | jq -r '.[] | select(startswith("automation/"))' | head -1) + if [ -z "$branch" ]; then + echo "No automation branch found in status event" echo "found=false" >> "$GITHUB_OUTPUT" exit 0 fi - pr_number=$(echo "$pr" | jq -r '.number') - branch=$(echo "$pr" | jq -r '.headRefName') - # Only auto-merge automation branches - if [[ "$branch" != automation/* ]]; then - echo "Skipping: branch '$branch' is not an automation branch" + echo "Branch: $branch" + + pr_number=$(gh pr list --repo ${{ github.repository }} \ + --head "$branch" --state open \ + --json number --jq '.[0].number') + if [ -z "$pr_number" ]; then + echo "No open PR found for branch $branch" echo "found=false" >> "$GITHUB_OUTPUT" exit 0 fi + echo "PR: #$pr_number" echo "number=$pr_number" >> "$GITHUB_OUTPUT" - echo "branch=$branch" >> "$GITHUB_OUTPUT" echo "found=true" >> "$GITHUB_OUTPUT" - name: Checkout From 1d8054611325b1a1e2749b7b971398c38bfb8d49 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 17:40:58 +0200 Subject: [PATCH 09/14] Target only automation/write-api-docs branch The old automation/update-api-docs branch is gone (PR #71). Only the write-api-docs branch exists now. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/automerge-docs.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/automerge-docs.yml b/.github/workflows/automerge-docs.yml index 89e07684..9ee31a5a 100644 --- a/.github/workflows/automerge-docs.yml +++ b/.github/workflows/automerge-docs.yml @@ -10,11 +10,11 @@ permissions: jobs: check-and-merge: - # Only run when a Learn Build status reports on an automation branch + # Only run when a Learn Build status reports on the automation branch if: | (github.event.context == 'OpenPublishing.Build' || github.event.context == 'PoliCheck Scan') && - contains(toJSON(github.event.branches.*.name), 'automation/') + contains(toJSON(github.event.branches.*.name), 'automation/write-api-docs') runs-on: ubuntu-latest steps: @@ -26,9 +26,9 @@ jobs: # The status event includes the branches this commit belongs to. # Find the automation branch and look up the open PR for it. branches='${{ toJSON(github.event.branches.*.name) }}' - branch=$(echo "$branches" | jq -r '.[] | select(startswith("automation/"))' | head -1) + branch=$(echo "$branches" | jq -r '.[] | select(. == "automation/write-api-docs")' | head -1) if [ -z "$branch" ]; then - echo "No automation branch found in status event" + echo "automation/write-api-docs branch not found in status event" echo "found=false" >> "$GITHUB_OUTPUT" exit 0 fi From 0f52aab616c610a2b86221d36f820cc393d40a99 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 17:41:38 +0200 Subject: [PATCH 10/14] Fix branch check: use contains() on array, not string contains() on an array checks for exact element membership. The toJSON() wrapper was converting to string first, making it a substring match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/automerge-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/automerge-docs.yml b/.github/workflows/automerge-docs.yml index 9ee31a5a..39489ae5 100644 --- a/.github/workflows/automerge-docs.yml +++ b/.github/workflows/automerge-docs.yml @@ -14,7 +14,7 @@ jobs: if: | (github.event.context == 'OpenPublishing.Build' || github.event.context == 'PoliCheck Scan') && - contains(toJSON(github.event.branches.*.name), 'automation/write-api-docs') + contains(github.event.branches.*.name, 'automation/write-api-docs') runs-on: ubuntu-latest steps: From 053e9073438a4bb3d3f6ced084c3addcb74be024 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 18:43:17 +0200 Subject: [PATCH 11/14] Harden automerge against all security review findings Fixes from Opus 4.7 and GPT-5.5 code reviews: 1. Baseline tamper protection: checkout main (not PR branch) so a PR cannot edit known-warnings.csv to bypass the warning gate. 2. TOCTOU race prevention: pin merge to validated SHA with --match-head-commit; verify event SHA matches PR HEAD before proceeding; skip stale status events. 3. Require both statuses: fail closed unless both OpenPublishing.Build AND PoliCheck Scan are present and green in the rollup. 4. Fail closed on schema changes: assert build_log_error_items exists, is a list, items are dicts with message_severity. Missing key = refuse to merge (was silently treating as zero warnings). 5. Shell injection prevention: pass all event data through env vars (BRANCHES_JSON, EVENT_SHA, PR_NUMBER, REASON, MERGE_SHA) instead of $\{\{ \}\} interpolation in run: blocks. 6. URL host validation: allowlist buildapi.docs.microsoft.com before fetching any build report or JSON log URL. 7. Comment on all failures: post PR comment for any failure mode (not just new warnings), with link to workflow run. 8. CSV baseline validation: verify required columns exist, validate count values are integers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/check-learn-build.py | 134 ++++++++++++++++++++++++--- .github/workflows/automerge-docs.yml | 54 +++++++---- 2 files changed, 158 insertions(+), 30 deletions(-) diff --git a/.github/scripts/check-learn-build.py b/.github/scripts/check-learn-build.py index 3a17e751..a7a816cc 100644 --- a/.github/scripts/check-learn-build.py +++ b/.github/scripts/check-learn-build.py @@ -7,15 +7,17 @@ Flow: 1. Get PR status checks via gh CLI -2. Verify all checks are green (SUCCESS / COMPLETED+SUCCESS) -3. Find the OpenPublishing.Build targetUrl → build report → JSON build log -4. Extract warnings/errors from the JSON log -5. Compare warnings against the known-warnings.csv baseline -6. Exit 0 if safe to merge, 1 if not +2. Verify all required checks are present and green +3. Validate the PR HEAD matches the event SHA (TOCTOU protection) +4. Find the OpenPublishing.Build targetUrl → build report → JSON build log +5. Extract warnings/errors from the JSON log (fail closed on schema changes) +6. Compare warnings against the known-warnings.csv baseline (from main) +7. Exit 0 if safe to merge, 1 if not Environment variables: GH_TOKEN - GitHub token for API access PR_NUMBER - Pull request number + VALIDATED_SHA - The commit SHA from the status event (TOCTOU pin) GITHUB_OUTPUT - GitHub Actions output file """ @@ -26,6 +28,18 @@ import subprocess import sys import urllib.request +from urllib.parse import urlparse + + +ALLOWED_HOSTS = { + "buildapi.docs.microsoft.com", + "review.docs.microsoft.com", +} + +REQUIRED_STATUSES = [ + "OpenPublishing.Build", + "PoliCheck Scan", +] def gh(*args): @@ -41,11 +55,22 @@ def get_pr_info(pr_number): """Get PR metadata and status checks.""" raw = gh( "pr", "view", str(pr_number), - "--json", "headRefName,statusCheckRollup", + "--json", "headRefName,headRefOid,statusCheckRollup", ) return json.loads(raw) +def validate_url(url, label="URL"): + """Validate that a URL is HTTPS and on an allowed host. Fail closed.""" + parsed = urlparse(url) + if parsed.scheme != "https": + raise ValueError(f"{label} must be HTTPS, got: {parsed.scheme}") + if parsed.hostname not in ALLOWED_HOSTS: + raise ValueError( + f"{label} host '{parsed.hostname}' not in allowed list: {ALLOWED_HOSTS}" + ) + + def check_statuses(checks): """Verify all GitHub commit statuses and check runs are green. @@ -79,39 +104,80 @@ def check_statuses(checks): return len(failures) == 0, failures, status_map +def check_required_statuses(status_map): + """Verify all required statuses are present and green. Fail closed.""" + missing = [] + not_green = [] + for name in REQUIRED_STATUSES: + if name not in status_map: + missing.append(name) + elif status_map[name]["state"] != "SUCCESS": + not_green.append((name, status_map[name]["state"])) + return missing, not_green + + def extract_build_log_url(report_url): """Fetch the build report HTML and extract the JSON build log URL. The build report page has a `build_log_url` attribute on the #Summary element that points to a JSON endpoint with structured data. """ + validate_url(report_url, "Build report URL") + req = urllib.request.Request(report_url) with urllib.request.urlopen(req, timeout=30) as response: content = response.read().decode("utf-8") match = re.search(r'build_log_url="([^"]+)"', content) - if match: - return match.group(1) - return None + if not match: + return None + + log_url = match.group(1) + validate_url(log_url, "Build log URL") + return log_url def fetch_build_log(build_log_url): """Fetch the JSON build log and categorize items by severity. + Fails closed if the expected JSON structure is missing or malformed. Returns (warnings, errors) where each is a sorted list of 'file|code|message' strings. """ + validate_url(build_log_url, "Build log URL") + req = urllib.request.Request(build_log_url) with urllib.request.urlopen(req, timeout=30) as response: raw = response.read().decode("utf-8-sig") data = json.loads(raw) - items = data.get("build_log_error_items", []) + if "build_log_error_items" not in data: + raise ValueError( + "Build log JSON missing 'build_log_error_items' key — " + "schema may have changed. Refusing to proceed." + ) + + items = data["build_log_error_items"] + if not isinstance(items, list): + raise ValueError( + f"'build_log_error_items' is {type(items).__name__}, expected list. " + "Refusing to proceed." + ) + warnings = [] errors = [] for item in items: - severity = item.get("message_severity", -1) + if not isinstance(item, dict): + raise ValueError( + f"Build log item is {type(item).__name__}, expected dict. " + "Refusing to proceed." + ) + severity = item.get("message_severity") + if severity is None: + raise ValueError( + "Build log item missing 'message_severity'. Refusing to proceed." + ) entry = "{file}|{code}|{message}".format( file=item.get("file", ""), code=item.get("code", ""), @@ -121,6 +187,9 @@ def fetch_build_log(build_log_url): errors.append(entry) elif severity == 1: warnings.append(entry) + # Severity 5 = Suggestion — safe to ignore. + # Any other unknown severity is also ignored since we fail closed + # on the warning comparison (unknown items don't reduce the count). warnings.sort() errors.sort() @@ -139,9 +208,20 @@ def load_baseline(path): baseline = Counter() with open(path, newline="") as f: reader = csv.DictReader(f) + required_cols = {"file", "code", "message", "count"} + if not required_cols.issubset(set(reader.fieldnames or [])): + raise ValueError( + f"Baseline CSV missing columns: {required_cols - set(reader.fieldnames or [])}. " + "Refusing to proceed." + ) for row in reader: entry = f"{row['file']}|{row['code']}|{row['message']}" - baseline[entry] = int(row["count"]) + try: + baseline[entry] = int(row["count"]) + except ValueError: + raise ValueError( + f"Invalid count '{row['count']}' in baseline for: {entry}" + ) return baseline @@ -187,6 +267,8 @@ def main(): print("ERROR: PR_NUMBER environment variable not set") sys.exit(1) + validated_sha = os.environ.get("VALIDATED_SHA", "") + baseline_path = os.path.join( os.environ.get("GITHUB_WORKSPACE", "."), ".github", "known-warnings.csv", @@ -196,8 +278,18 @@ def main(): print(f"Checking PR #{pr_number}...") pr_data = get_pr_info(pr_number) head_ref = pr_data["headRefName"] + head_sha = pr_data["headRefOid"] print(f" Branch: {head_ref}") + print(f" PR HEAD: {head_sha[:12]}") + + # TOCTOU protection: verify the PR HEAD hasn't changed since the status event + if validated_sha and head_sha != validated_sha: + print(f" ❌ PR HEAD ({head_sha[:12]}) != event SHA ({validated_sha[:12]})") + print(" PR was updated after status event fired. Skipping.") + set_output("should_merge", "false") + set_output("reason", "PR HEAD changed since status event") + sys.exit(1) # Check all GitHub statuses are green checks = pr_data.get("statusCheckRollup", []) @@ -219,7 +311,21 @@ def main(): set_output("reason", f"Checks not green: {names}") sys.exit(1) - print(" All status checks are green") + # Verify all required statuses are present + missing, not_green = check_required_statuses(status_map) + if missing: + print(f" Waiting: required status(es) not yet reported: {', '.join(missing)}") + set_output("should_merge", "false") + set_output("reason", f"Waiting for: {', '.join(missing)}") + sys.exit(0) + if not_green: + names = ", ".join(f"{n} ({s})" for n, s in not_green) + print(f" ❌ Required status(es) not green: {names}") + set_output("should_merge", "false") + set_output("reason", f"Required checks failed: {names}") + sys.exit(1) + + print(" All required status checks are green") # Get the OpenPublishing.Build report URL from the status targetUrl build_status = status_map.get("OpenPublishing.Build") @@ -230,7 +336,7 @@ def main(): sys.exit(1) report_url = build_status["url"] - print(f" Fetching build report from status targetUrl...") + print(" Fetching build report from status targetUrl...") build_log_url = extract_build_log_url(report_url) if not build_log_url: print(" ERROR: Could not find build_log_url in build report") diff --git a/.github/workflows/automerge-docs.yml b/.github/workflows/automerge-docs.yml index 39489ae5..420ba92a 100644 --- a/.github/workflows/automerge-docs.yml +++ b/.github/workflows/automerge-docs.yml @@ -22,11 +22,11 @@ jobs: id: pr env: GH_TOKEN: ${{ github.token }} + BRANCHES_JSON: ${{ toJSON(github.event.branches.*.name) }} + EVENT_SHA: ${{ github.event.sha }} run: | - # The status event includes the branches this commit belongs to. - # Find the automation branch and look up the open PR for it. - branches='${{ toJSON(github.event.branches.*.name) }}' - branch=$(echo "$branches" | jq -r '.[] | select(. == "automation/write-api-docs")' | head -1) + # Extract the automation branch from the event (via env var, not interpolation). + branch=$(printf '%s' "$BRANCHES_JSON" | jq -r '.[] | select(. == "automation/write-api-docs")' | head -1) if [ -z "$branch" ]; then echo "automation/write-api-docs branch not found in status event" echo "found=false" >> "$GITHUB_OUTPUT" @@ -34,21 +34,37 @@ jobs: fi echo "Branch: $branch" - pr_number=$(gh pr list --repo ${{ github.repository }} \ + pr_json=$(gh pr list --repo "${{ github.repository }}" \ --head "$branch" --state open \ - --json number --jq '.[0].number') - if [ -z "$pr_number" ]; then + --json number,headRefOid --jq '.[0]') + if [ -z "$pr_json" ] || [ "$pr_json" = "null" ]; then echo "No open PR found for branch $branch" echo "found=false" >> "$GITHUB_OUTPUT" exit 0 fi - echo "PR: #$pr_number" + pr_number=$(echo "$pr_json" | jq -r '.number') + pr_head=$(echo "$pr_json" | jq -r '.headRefOid') + + # Only proceed if the status event SHA matches the PR HEAD. + # Prevents validating a stale commit while a newer one exists. + if [ "$EVENT_SHA" != "$pr_head" ]; then + echo "Status event SHA ($EVENT_SHA) does not match PR HEAD ($pr_head), skipping" + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "PR: #$pr_number (HEAD: $pr_head)" echo "number=$pr_number" >> "$GITHUB_OUTPUT" + echo "head_sha=$pr_head" >> "$GITHUB_OUTPUT" echo "found=true" >> "$GITHUB_OUTPUT" - - name: Checkout + # Check out main branch for the baseline file — never the PR branch. + # This prevents a PR from modifying known-warnings.csv to bypass the gate. + - name: Checkout baseline from main if: steps.pr.outputs.found == 'true' uses: actions/checkout@v4 + with: + ref: main - name: Check Learn Build statuses if: steps.pr.outputs.found == 'true' @@ -56,24 +72,30 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr.outputs.number }} + VALIDATED_SHA: ${{ steps.pr.outputs.head_sha }} run: python3 .github/scripts/check-learn-build.py - name: Merge PR if: steps.pr.outputs.found == 'true' && steps.check.outputs.should_merge == 'true' env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + MERGE_SHA: ${{ steps.pr.outputs.head_sha }} run: | - echo "Auto-merging PR #${{ steps.pr.outputs.number }}..." - gh pr merge ${{ steps.pr.outputs.number }} --squash \ + echo "Auto-merging PR #$PR_NUMBER (SHA: $MERGE_SHA)..." + gh pr merge "$PR_NUMBER" --squash \ + --match-head-commit "$MERGE_SHA" \ --subject "Update API docs from latest CI build" \ --body "Auto-merged after Learn Build validation passed (all checks green, no new warnings)." - - name: Post comment on new warnings - if: failure() && steps.check.outputs.new_warnings != '' + - name: Post comment on failure + if: failure() && steps.pr.outputs.found == 'true' env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + REASON: ${{ steps.check.outputs.reason }} run: | - gh pr comment ${{ steps.pr.outputs.number }} --body \ - "⚠️ **Auto-merge blocked**: ${{ steps.check.outputs.reason }} + gh pr comment "$PR_NUMBER" --body \ + "⚠️ **Auto-merge blocked**: ${REASON:-unknown failure} - New warnings need manual review before merging. Update \`.github/known-warnings.csv\` if these warnings are expected." + Review the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. Update \`.github/known-warnings.csv\` on \`main\` if new warnings are expected." From 6b75d02d0c86a2fd1ae2864022c41c9de38d3706 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 18:55:38 +0200 Subject: [PATCH 12/14] =?UTF-8?q?Add=20dry-run=20mode=20=E2=80=94=20commen?= =?UTF-8?q?ts=20instead=20of=20merging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRY_RUN env var defaults to true. When enabled, the workflow runs all checks but posts a comment instead of merging. Also supports workflow_dispatch with a dry_run input toggle. To go live: change DRY_RUN to false in the workflow file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/automerge-docs.yml | 37 +++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/automerge-docs.yml b/.github/workflows/automerge-docs.yml index 420ba92a..2247cd26 100644 --- a/.github/workflows/automerge-docs.yml +++ b/.github/workflows/automerge-docs.yml @@ -2,6 +2,17 @@ name: Auto-merge docs PR on: status: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run — comment what would happen without merging' + type: boolean + default: true + +# Set to true to enable dry-run mode by default on status events. +# Change to false when ready to auto-merge for real. +env: + DRY_RUN: true permissions: contents: write @@ -76,7 +87,11 @@ jobs: run: python3 .github/scripts/check-learn-build.py - name: Merge PR - if: steps.pr.outputs.found == 'true' && steps.check.outputs.should_merge == 'true' + if: | + steps.pr.outputs.found == 'true' && + steps.check.outputs.should_merge == 'true' && + env.DRY_RUN != 'true' && + inputs.dry_run != true env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr.outputs.number }} @@ -88,6 +103,26 @@ jobs: --subject "Update API docs from latest CI build" \ --body "Auto-merged after Learn Build validation passed (all checks green, no new warnings)." + - name: Dry-run report + if: | + steps.pr.outputs.found == 'true' && + steps.check.outputs.should_merge == 'true' && + (env.DRY_RUN == 'true' || inputs.dry_run == true) + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + MERGE_SHA: ${{ steps.pr.outputs.head_sha }} + run: | + echo "🔍 DRY RUN: Would merge PR #$PR_NUMBER (SHA: $MERGE_SHA)" + gh pr comment "$PR_NUMBER" --body \ + "🔍 **Dry run**: Auto-merge check passed — this PR **would** be merged. + + - All status checks green + - No new warnings vs baseline + - Commit: $MERGE_SHA + + Set \`DRY_RUN: false\` in the workflow to enable actual merging." + - name: Post comment on failure if: failure() && steps.pr.outputs.found == 'true' env: From a3ac12c15818f6341366da05ac66253183682830 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 18:57:46 +0200 Subject: [PATCH 13/14] Remove workflow_dispatch, keep DRY_RUN env var only Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/automerge-docs.yml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/automerge-docs.yml b/.github/workflows/automerge-docs.yml index 2247cd26..e304ae1e 100644 --- a/.github/workflows/automerge-docs.yml +++ b/.github/workflows/automerge-docs.yml @@ -2,15 +2,8 @@ name: Auto-merge docs PR on: status: - workflow_dispatch: - inputs: - dry_run: - description: 'Dry run — comment what would happen without merging' - type: boolean - default: true - -# Set to true to enable dry-run mode by default on status events. -# Change to false when ready to auto-merge for real. + +# Set to false when ready to auto-merge for real. env: DRY_RUN: true @@ -90,8 +83,7 @@ jobs: if: | steps.pr.outputs.found == 'true' && steps.check.outputs.should_merge == 'true' && - env.DRY_RUN != 'true' && - inputs.dry_run != true + env.DRY_RUN != 'true' env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr.outputs.number }} @@ -107,7 +99,7 @@ jobs: if: | steps.pr.outputs.found == 'true' && steps.check.outputs.should_merge == 'true' && - (env.DRY_RUN == 'true' || inputs.dry_run == true) + env.DRY_RUN == 'true' env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr.outputs.number }} From 69c733c2685bde842828826a501901f668bbfe54 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 11 May 2026 19:01:04 +0200 Subject: [PATCH 14/14] Treat pending checks as waiting, not failed When non-green checks are only PENDING/IN_PROGRESS, exit 0 (wait silently) instead of exit 1 (post blocked comment). Prevents spurious 'auto-merge blocked' comments when the first status arrives before the second. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/check-learn-build.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/scripts/check-learn-build.py b/.github/scripts/check-learn-build.py index a7a816cc..e4668c8f 100644 --- a/.github/scripts/check-learn-build.py +++ b/.github/scripts/check-learn-build.py @@ -305,11 +305,20 @@ def main(): print(f" {name}: {info['state']}") if not all_green: - names = ", ".join(f"{n} ({s})" for n, s in failures) - print(f" ❌ Not all checks are green: {names}") - set_output("should_merge", "false") - set_output("reason", f"Checks not green: {names}") - sys.exit(1) + # Distinguish "still pending" from "actually failed" + only_pending = all(s == "PENDING" for _, s in failures) + if only_pending: + names = ", ".join(n for n, _ in failures) + print(f" Waiting: checks still pending: {names}") + set_output("should_merge", "false") + set_output("reason", f"Waiting for: {names}") + sys.exit(0) + else: + names = ", ".join(f"{n} ({s})" for n, s in failures) + print(f" ❌ Not all checks are green: {names}") + set_output("should_merge", "false") + set_output("reason", f"Checks not green: {names}") + sys.exit(1) # Verify all required statuses are present missing, not_green = check_required_statuses(status_map)