From 4d5142374557cf2806f1e3409cc1fd97e226a732 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 9 Oct 2026 01:43:14 -0700 Subject: [PATCH 01/15] test: red test, a global notification center passed as a center argument fails the lint The lint caught only a literal NotificationCenter.default.post. The off-main notice tests posted through OffMainPost.send() with .default handed in (on: .default, or a NotificationCenter = .default parameter default), and ViewBridge's own didBecomeKey observer trapped the whole swift test run (#18815). Those forms must fail the gate too. Co-Authored-By: Claude Opus 5.5 --- .../tests/check-test-notification-centers.test.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/cmux-next/tests/check-test-notification-centers.test.sh b/scripts/cmux-next/tests/check-test-notification-centers.test.sh index 25ee9ce5de0a..8c216bacab45 100644 --- a/scripts/cmux-next/tests/check-test-notification-centers.test.sh +++ b/scripts/cmux-next/tests/check-test-notification-centers.test.sh @@ -28,6 +28,20 @@ fail "a default-center postNotificationName" ' NotificationCenter.default fail "a workspace-center post" ' NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.didWakeNotification, object: nil)' fail "a distributed-center post" ' DistributedNotificationCenter.default().post(name: .x, object: nil)' fail "a distributed-center postNotificationName" ' DistributedNotificationCenter.default().postNotificationName(.x, object: nil, userInfo: nil, deliverImmediately: true)' +# A global center handed to a poster reaches every observer the same way: +# OffMainPost.send() posted didBecomeKey off main on .default, passed in, and +# ViewBridge's own observer trapped (#18815). +fail "a default center as a center argument" ' await postOffMain(NSWindow.didBecomeKeyNotification, object: window, on: .default)' +fail "a spelled-out default center as an argument" ' let post = OffMainPost(name: .x, object: nil, center: NotificationCenter.default)' +fail "the workspace center as an argument" ' await postOffMain(.x, object: nil, on: NSWorkspace.shared.notificationCenter)' +fail "the distributed center as an argument" ' poster(center: DistributedNotificationCenter.default())' +fail "a default center as a parameter default" 'private func postOffMain(_ name: Notification.Name, object: AnyObject?, on center: NotificationCenter = .default) async {' +fail "a spelled-out default center as a parameter default" 'func post(_ name: Notification.Name, center: NotificationCenter = NotificationCenter.default) {' +pass "another default as an argument" ' let session = URLSession(configuration: .default)' +pass "an observer token on the default center" ' let token = NotificationCenter.default.addObserver(forName: .x, object: nil, queue: .main) { _ in }' +pass "removing an observer from the default center" ' NotificationCenter.default.removeObserver(token)' +pass "an injected center as an argument" ' await postOffMain(.x, object: nil, on: center)' +pass "a reviewed default center argument" ' await postOffMain(.x, object: window, on: .default) // global-notice-allow: proves the app observer hops to main' pass "an injected center" ' center.post(name: NSWindow.willCloseNotification, object: window)' pass "a private center" ' NotificationCenter().post(name: .x, object: nil)' pass "observing the default center" ' let token = NotificationCenter.default.addObserver(forName: .x, object: window, queue: nil) { _ in }' From 63ab4a0bdabbb2f87715249448389a3da8457037 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 9 Oct 2026 01:44:16 -0700 Subject: [PATCH 02/15] ci: the notification-center lint also fails a global center handed to a poster check-test-notification-centers.sh now also fails a process-global center passed as an argument (on: .default, center: NotificationCenter.default, the workspace or distributed center) or as a NotificationCenter parameter's default. The off-main notice tests posted through OffMainPost.send() that way, and ViewBridge's own didBecomeKey observer trapped the whole swift test run (#18815). The 11 existing sites, all deliberate off-main posts that prove an app observer hops to main, carry a reviewed global-notice-allow naming that risk; new ones must inject a center. Co-Authored-By: Claude Opus 5.5 --- .../AppOffMainNotificationTests.swift | 1 + .../WebKitInspectorOffMainTests.swift | 1 + .../DesignCallbackThreadTests.swift | 1 + .../OffMainNotificationTests.swift | 3 +++ .../LayoutOffMainNotificationTests.swift | 1 + .../PageOffMainNotificationTests.swift | 1 + .../PaletteOffMainNotificationTests.swift | 1 + .../SidebarOffMainNotificationTests.swift | 1 + .../TerminalScrollerOffMainTests.swift | 1 + .../check-test-notification-centers.sh | 22 +++++++++++++++---- 10 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AppOffMainNotificationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AppOffMainNotificationTests.swift index dd6cc4b30b7f..96aa94f0a890 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AppOffMainNotificationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AppOffMainNotificationTests.swift @@ -5,6 +5,7 @@ import Testing @testable import CmuxNextApp /// Posts `name` from a background thread and returns when the post returned. +// global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) private func postOffMain(_ name: Notification.Name, object: AnyObject?, on center: NotificationCenter = .default) async { let post = OffMainPost(name: name, object: object, center: center) await withCheckedContinuation { (done: CheckedContinuation) in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitInspectorOffMainTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitInspectorOffMainTests.swift index b1c299f7e65e..93fe62eb5721 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitInspectorOffMainTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitInspectorOffMainTests.swift @@ -5,6 +5,7 @@ import WebKit @testable import CmuxNextBrowser /// Posts `name` from a background thread and returns when the post returned. +// global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) private func postOffMain(_ name: Notification.Name, object: AnyObject?, on center: NotificationCenter = .default) async { let post = OffMainPost(name: name, object: object, center: center) await withCheckedContinuation { (done: CheckedContinuation) in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/DesignCallbackThreadTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/DesignCallbackThreadTests.swift index e54f3a92c0cd..81fb7875f487 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/DesignCallbackThreadTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/DesignCallbackThreadTests.swift @@ -14,6 +14,7 @@ import Testing /// Posts `name` from a detached thread, then waits one main-queue turn, /// which runs after any hop the observer enqueued. private func postFromBackground(_ name: Notification.Name, object: (any Sendable)? = nil, + // global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) on center: NotificationCenter = .default) async { await withCheckedContinuation { (done: CheckedContinuation) in Thread.detachNewThread { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/OffMainNotificationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/OffMainNotificationTests.swift index a250e96ea314..8d9ac574d261 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/OffMainNotificationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/OffMainNotificationTests.swift @@ -75,6 +75,7 @@ struct OffMainNotificationTests { PointerHover.setDebugPointer(NSPoint(x: 40, y: 30), in: window) #expect(PointerHover.debugPointers[ObjectIdentifier(window)] != nil) + // global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) await postOffMain(NSWindow.willCloseNotification, object: window, on: .default) await waitOnMain { PointerHover.debugPointers[ObjectIdentifier(window)] == nil } #expect(PointerHover.debugPointers[ObjectIdentifier(window)] == nil) @@ -95,6 +96,7 @@ struct OffMainNotificationTests { PointerHover.debugPointers[ObjectIdentifier(window)] = .init(point: NSPoint(x: 40, y: 30), mouse: NSEvent.mouseLocation) #expect(!hover.isHovering) + // global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) await postOffMain(NSWindow.didBecomeKeyNotification, object: window, on: .default) await waitOnMain { hover.isHovering } #expect(hover.isHovering) @@ -115,6 +117,7 @@ struct OffMainNotificationTests { let before = log.calls await postOffMain(NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil, + // global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) on: NSWorkspace.shared.notificationCenter) await waitOnMain { log.calls > before } #expect(log.calls > before) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextLayoutTests/LayoutOffMainNotificationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextLayoutTests/LayoutOffMainNotificationTests.swift index 6f3563cf79f5..36e20282d30e 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextLayoutTests/LayoutOffMainNotificationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextLayoutTests/LayoutOffMainNotificationTests.swift @@ -5,6 +5,7 @@ import Testing @testable import CmuxNextLayout /// Posts `name` from a background thread and returns when the post returned. +// global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) private func postOffMain(_ name: Notification.Name, object: AnyObject?, on center: NotificationCenter = .default) async { let post = OffMainPost(name: name, object: object, center: center) await withCheckedContinuation { (done: CheckedContinuation) in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextPagesTests/PageOffMainNotificationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextPagesTests/PageOffMainNotificationTests.swift index b0d3ab0c6423..c1c079b23eaf 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextPagesTests/PageOffMainNotificationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextPagesTests/PageOffMainNotificationTests.swift @@ -5,6 +5,7 @@ import WebKit @testable import CmuxNextPages /// Posts `name` from a background thread and returns when the post returned. +// global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) private func postOffMain(_ name: Notification.Name, object: AnyObject?, on center: NotificationCenter = .default) async { let post = OffMainPost(name: name, object: object, center: center) await withCheckedContinuation { (done: CheckedContinuation) in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextPaletteTests/PaletteOffMainNotificationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextPaletteTests/PaletteOffMainNotificationTests.swift index 63bff8831b13..c0dfea8bf993 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextPaletteTests/PaletteOffMainNotificationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextPaletteTests/PaletteOffMainNotificationTests.swift @@ -4,6 +4,7 @@ import Testing @testable import CmuxNextPalette /// Posts `name` from a background thread and returns when the post returned. +// global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) private func postOffMain(_ name: Notification.Name, object: AnyObject?, on center: NotificationCenter = .default) async { let post = OffMainPost(name: name, object: object, center: center) await withCheckedContinuation { (done: CheckedContinuation) in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextSidebarTests/SidebarOffMainNotificationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextSidebarTests/SidebarOffMainNotificationTests.swift index 139a20f87d90..0192f6596cf7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextSidebarTests/SidebarOffMainNotificationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextSidebarTests/SidebarOffMainNotificationTests.swift @@ -5,6 +5,7 @@ import Testing @testable import CmuxNextSidebar /// Posts `name` from a background thread and returns when the post returned. +// global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) private func postOffMain(_ name: Notification.Name, object: AnyObject?, on center: NotificationCenter = .default) async { let post = OffMainPost(name: name, object: object, center: center) await withCheckedContinuation { (done: CheckedContinuation) in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextTerminalTests/TerminalScrollerOffMainTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextTerminalTests/TerminalScrollerOffMainTests.swift index 5e4cac9b99ba..3c92ebf77b60 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextTerminalTests/TerminalScrollerOffMainTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextTerminalTests/TerminalScrollerOffMainTests.swift @@ -4,6 +4,7 @@ import Testing @testable import CmuxNextTerminal /// Posts `name` from a background thread and returns when the post returned. +// global-notice-allow: deliberate off-main post on the real center to prove the app observer hops to main (crash-elimination P1b); AppKit's own observers of it can trap (#18815) private func postOffMain(_ name: Notification.Name, object: AnyObject?, on center: NotificationCenter = .default) async { let post = OffMainPost(name: name, object: object, center: center) await withCheckedContinuation { (done: CheckedContinuation) in diff --git a/scripts/cmux-next/check-test-notification-centers.sh b/scripts/cmux-next/check-test-notification-centers.sh index 37f8412d9383..380b26512aaa 100755 --- a/scripts/cmux-next/check-test-notification-centers.sh +++ b/scripts/cmux-next/check-test-notification-centers.sh @@ -5,8 +5,13 @@ # gets that post on the posting thread, so one test's notice reaches another # test's or the app's observers: a willClose posted off main on # NotificationCenter.default trapped a @MainActor observer and killed the -# whole swift test run with signal 5 (#18771). Inject a NotificationCenter -# into the type under test and post to it instead. +# whole swift test run with signal 5 (#18771). Handing one of those centers +# to a poster is the same post: OffMainPost.send() posted didBecomeKey off main +# on .default, passed in, and ViewBridge's own observer trapped (#18815). So a +# global center as an argument (`on: .default`, `center: +# NotificationCenter.default`, ...) or as a NotificationCenter parameter's +# default value fails too. Inject a NotificationCenter into the type under +# test and post to it instead. # # A hit is allowed only with a reviewed `// global-notice-allow: ` on # the same line or the comment line above. Observing a global center is fine. @@ -20,6 +25,13 @@ import os, re, subprocess, sys tests = os.path.join(sys.argv[1], "Tests") POST = re.compile(r"(\bNotificationCenter\.default|\bNSWorkspace\.shared\.notificationCenter" r"|\bDistributedNotificationCenter\.default\(\))\s*\.\s*post(NotificationName)?\(") +GLOBAL = (r"(?:NotificationCenter\.default|NSWorkspace\.shared\.notificationCenter" + r"|DistributedNotificationCenter\.default\(\))") +# A global center handed to a poster: any labeled argument spelled out, a bare +# `.default` for a center label, or a NotificationCenter parameter default. +PASSED = re.compile(rf"\b\w+\s*:\s*{GLOBAL}\s*[,)]" + r"|\b(?:on|center|notificationCenter)\s*:\s*\.default\s*[,)]" + rf"|:\s*(?:Distributed)?NotificationCenter\s*=\s*(?:\.default\b|{GLOBAL})") ALLOW = re.compile(r"//\s*global-notice-allow:\s*\S") def code_of(line): @@ -44,11 +56,13 @@ for path in swift_tests(): continue lines = open(path, encoding="utf-8").read().split("\n") for index, line in enumerate(lines): - if line.lstrip().startswith("//") or not POST.search(code_of(line)): + code = code_of(line) + if line.lstrip().startswith("//") or not (POST.search(code) or PASSED.search(code)): continue if ALLOW.search(line) or (index > 0 and ALLOW.search(lines[index - 1])): continue - failures.append(f"{os.path.relpath(path, os.path.dirname(tests))}:{index + 1}: posts to a process-global notification center") + what = "posts to" if POST.search(code) else "hands a poster" + failures.append(f"{os.path.relpath(path, os.path.dirname(tests))}:{index + 1}: {what} a process-global notification center") for failure in failures: print("test-notification-centers: " + failure) From 0d94be2c549650c34e7c9e1ab511211bc7631506 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:22:48 -0700 Subject: [PATCH 03/15] fix(messageslab): a Chief subagent link check is a URL property, not a static-only type [cx-94dl] 35a61bd340b7 added enum HomeAppLinks (all-static public surface), which lint-ios-package-conventions.sh refuses; cdd28b4be39b got past the lint by making it a struct with an empty public init. URL.isChiefSubagentLink is the same check on the receiver type; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../MessagesLabHome/Cmux/HomeMarkdown.swift | 4 ++-- .../MessagesLabHome/Cmux/HomeMarkdownPolicy.swift | 15 ++++++--------- .../Cmux/MessagesLabHomeView.swift | 2 +- .../Sources/MessagesLabHome/Cmux/PaneHost.swift | 2 +- .../Sources/MessagesLabHome/Cmux/PaneLinks.swift | 2 +- .../Vendor/catalyst/MarkdownParser.swift | 2 +- .../MessagesLabHomeTests/PaneLinkClickTests.swift | 8 ++++---- 7 files changed, 16 insertions(+), 19 deletions(-) diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMarkdown.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMarkdown.swift index 46baeaa85289..d1a5327a8623 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMarkdown.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMarkdown.swift @@ -8,7 +8,7 @@ import Foundation /// monospaced), `#` headings (bold), `-`/`*`/`+` bullets (`•`), numbered /// items (kept), and inline `**bold**`/`__bold__`, `*italic*`/`_italic_`, /// `~~strike~~`, `` `code` ``, `[label](url)` and ``. Only http, https -/// and mailto URLs, and a Chief subagent link (HomeAppLinks), become links; +/// and mailto URLs, and a Chief subagent link (URL.isChiefSubagentLink), become links; /// another scheme shows its label as text. /// Anything else (HTML, tables, images) stays as written. Offsets are UTF-16, /// like MessagesLab's runs. @@ -206,7 +206,7 @@ enum HomeMarkdown { } /// The one link rule (MarkdownLinkPolicy: http, https, mailto, and Home's app form, - /// a Chief subagent link, HomeAppLinks), so this renderer and MessagesLab's agree. + /// a Chief subagent link, URL.isChiefSubagentLink), so this renderer and MessagesLab's agree. private static func safe(_ url: String) -> String? { MarkdownLinkPolicy.sanitize(url) } } } diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMarkdownPolicy.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMarkdownPolicy.swift index 73c128495433..d0d8e4ed9fdb 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMarkdownPolicy.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/HomeMarkdownPolicy.swift @@ -24,9 +24,9 @@ enum HomeMarkdownPolicy { guard !was else { return } was = true // Only http, https and mailto become links, and one app form: a Chief - // subagent's link (HomeAppLinks), which a click hands to the app. + // subagent's link (URL.isChiefSubagentLink), which a click hands to the app. MarkdownLinkPolicy.extraSchemes = [] - MarkdownLinkPolicy.extraRule = HomeAppLinks.isSubagentLink + MarkdownLinkPolicy.extraRule = { $0.isChiefSubagentLink } MarkdownImages.provider = images } } @@ -37,13 +37,10 @@ enum HomeMarkdownPolicy { /// home id 8 lowercase hex digits, session id 1 to 200 of `A-Za-z0-9-_.`; no user, port, /// query or fragment). A click never goes to the system: the host's `onAppLink` runs it /// (the app's `link.open`, for its own Chief's subagents only). -public struct HomeAppLinks { - public init() {} - public static let scheme = "cmux" - - /// Whether `url` is a Chief subagent link (Home makes it a link and a click opens it in the app). - public static func isSubagentLink(_ url: URL) -> Bool { - guard url.scheme == scheme, let c = URLComponents(url: url, resolvingAgainstBaseURL: false), +extension URL { + /// Whether this URL is a Chief subagent link (Home makes it a link and a click opens it in the app). + public var isChiefSubagentLink: Bool { + guard scheme == "cmux", let c = URLComponents(url: self, resolvingAgainstBaseURL: false), c.user == nil, c.password == nil, c.port == nil, c.query == nil, c.fragment == nil, c.host == "chief" else { return false } let parts = c.percentEncodedPath.split(separator: "/", omittingEmptySubsequences: false) diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/MessagesLabHomeView.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/MessagesLabHomeView.swift index 355014b74ff9..f5c3e97f5f9b 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/MessagesLabHomeView.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/MessagesLabHomeView.swift @@ -121,7 +121,7 @@ public final class MessagesLabHomeView: NSView { } } - /// cmux: a click on a Chief subagent's link in agent text (`HomeAppLinks`); the host + /// cmux: a click on a Chief subagent's link in agent text (`URL.isChiefSubagentLink`); the host /// opens it in the app (`link.open`). Nil: the link opens nothing. public var onAppLink: ((URL) -> Void)? { get { controller.onAppLink } diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneHost.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneHost.swift index 5090df5cf435..3d68525e6cb1 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneHost.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneHost.swift @@ -262,7 +262,7 @@ final class ChatController: NSObject, NSTextViewDelegate { private(set) var demo: MessagesWindowView? /// cmux: where the user's changes go (the HomeStore adapter). weak var intents: ChatIntents? - /// cmux: a click on an app link (`HomeAppLinks`): the host runs it; nil opens nothing. + /// cmux: a click on an app link (`URL.isChiefSubagentLink`): the host runs it; nil opens nothing. var onAppLink: ((URL) -> Void)? /// cmux: one-shot wake-ups on the host's timer (CmuxNext: DemandTimer). let wake: ChatWakeScheduler diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneLinks.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneLinks.swift index 5a928cc6f99d..f2c512d7884e 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneLinks.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaneLinks.swift @@ -32,7 +32,7 @@ extension ChatController { switch linkTarget(hit, at: p) { case let .text(url)?: // An app link (a Chief subagent) goes to the host, never to the system. - if let url, HomeAppLinks.isSubagentLink(url) { onAppLink?(url) } else if let url { NSWorkspace.shared.open(url) } + if let url, url.isChiefSubagentLink { onAppLink?(url) } else if let url { NSWorkspace.shared.open(url) } case let .card(raw, url)?: if let url { intents?.linkTapped(hit.row.ref, url: raw); NSWorkspace.shared.open(url) } case nil: diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/MarkdownParser.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/MarkdownParser.swift index 400718d57373..de4b4784171c 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/MarkdownParser.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/MarkdownParser.swift @@ -56,7 +56,7 @@ enum MarkdownLinkPolicy { set { lock.lock(); extra = Set(newValue.map { $0.lowercased() }); lock.unlock() } } // cmux: one exact link form of a scheme that is not allowed whole (Home's Chief subagent - // links, `HomeAppLinks`): a URL of another scheme is a link only when this rule allows it. + // links, `URL.isChiefSubagentLink`): a URL of another scheme is a link only when this rule allows it. // Set at launch with `extraSchemes`; nil allows nothing more. private static var rule: (@Sendable (URL) -> Bool)? static var extraRule: (@Sendable (URL) -> Bool)? { diff --git a/Packages/Shared/CmuxMessagesLab/Tests/MessagesLabHomeTests/PaneLinkClickTests.swift b/Packages/Shared/CmuxMessagesLab/Tests/MessagesLabHomeTests/PaneLinkClickTests.swift index 8e345df60813..0e0706adfbeb 100644 --- a/Packages/Shared/CmuxMessagesLab/Tests/MessagesLabHomeTests/PaneLinkClickTests.swift +++ b/Packages/Shared/CmuxMessagesLab/Tests/MessagesLabHomeTests/PaneLinkClickTests.swift @@ -13,14 +13,14 @@ import Testing #expect(MDInlineParser.parse("[x](javascript:alert(1))").spans.compactMap(\.link).isEmpty) } - /// A Chief subagent's link in agent text is a link (`HomeAppLinks`: only + /// A Chief subagent's link in agent text is a link (`URL.isChiefSubagentLink`: only /// `cmux://chief//session/`), and a click hands it to the host's app-link /// handler (the app's `link.open`), never to the system; any other cmux:// text stays text. @Test func aSubagentLinkIsALinkAndItsClickGoesToTheApp() throws { HomeMarkdownPolicy.installed = false let (p, c) = Fixture2.projection() let link = "cmux://chief/0a1b2c3d/session/01a12318-9c7f-7000-beab-7686172b0ca3" - #expect(HomeAppLinks.isSubagentLink(try #require(URL(string: link)))) + #expect(try #require(URL(string: link)).isChiefSubagentLink) #expect(MarkdownLinkPolicy.sanitize(link) == link) #expect(MDInlineParser.parse("[a1](\(link))").spans.compactMap(\.link) == [link]) // Its own message id: the measure cache keys by message id, not text (another test's msg_1). @@ -46,8 +46,8 @@ import Testing let hit = try #require(c.demo?.hit(point)) #expect(c.openLink(hit, at: point)) #expect(opened.map(\.absoluteString) == [link]) - #expect(!HomeAppLinks.isSubagentLink(URL(string: "cmux://chief/0a1b2c3d/session/s1?x=1")!)) - #expect(!HomeAppLinks.isSubagentLink(URL(string: "cmux-dev://chief/0a1b2c3d/session/s1")!)) + #expect(!URL(string: "cmux://chief/0a1b2c3d/session/s1?x=1")!.isChiefSubagentLink) + #expect(!URL(string: "cmux-dev://chief/0a1b2c3d/session/s1")!.isChiefSubagentLink) } @Test func aClickOpensOnlyHttpHttpsAndMailto() { From 20cd2f6c7a6b4dfa1ab3bc0073f45d61270bfe57 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:26:21 -0700 Subject: [PATCH 04/15] ci: a base autofix bot commits generated files, tree inputs and fmt (#18896) * test(ci): the base autofix regenerates without touching frozen paths (red) base-autofix.sh copies the Mac job's regenerated files into a checkout, skipping frozen paths, and adds a missing cmux-tui tree input to both lists. Red: the script does not exist yet. Co-Authored-By: Claude Opus 5.5 * ci: a base autofix bot commits generated files, tree inputs and fmt After each cmux-next run on a feat-cmux-next push, cmux-next-base-autofix repairs the mechanical reds in one commit on top of it: the generated files the Mac generated-files job rebuilt (its cmux-next-generated-patch artifact, so no fleet Mac), missing cmux-tui tree inputs and cargo fmt in cmux-tui. Frozen paths (base-autofix-frozen.txt) are reported and never written; the app FFI pin stays with the app-ffi-repin pull request. The push uses a GitHub App token minted only for the push step and a plain push that never overwrites a moved base. Dry run until the repo variable CMUX_NEXT_BASE_AUTOFIX is 1. Co-Authored-By: Claude Opus 5.5 * test(ci): the base autofix never writes a workflow file (red) The bot pushes with GITHUB_TOKEN, which cannot push .github/workflows changes. A regenerated workflow file is skipped and reported, and a missing cmux-tui tree input (its second list is cmux-tui-artifacts.yml's paths) is reported without writing either list. Red: both are written. Co-Authored-By: Claude Opus 5.5 * ci: the base autofix pushes as github-actions[bot] with GITHUB_TOKEN No GitHub App: the job's GITHUB_TOKEN (contents: write) pushes the repair; no ruleset or branch protection applies to feat-cmux-next. GITHUB_TOKEN cannot push workflow files, so every .github/workflows path is frozen for the bot, and missing cmux-tui tree inputs (whose second list is cmux-tui-artifacts.yml's paths) are reported, not written. Its pushes start no workflow: the next push's cmux-next run verifies the repair commit. The trigger also checks the run's workflow path. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/cmux-next-base-autofix.yml | 118 +++++++++++++++++++ .github/workflows/cmux-next.yml | 1 + scripts/cmux-next/base-autofix-frozen.txt | 14 +++ scripts/cmux-next/base-autofix.sh | 78 ++++++++++++ scripts/cmux-next/tests/base-autofix.test.sh | 48 ++++++++ 5 files changed, 259 insertions(+) create mode 100644 .github/workflows/cmux-next-base-autofix.yml create mode 100644 scripts/cmux-next/base-autofix-frozen.txt create mode 100755 scripts/cmux-next/base-autofix.sh create mode 100755 scripts/cmux-next/tests/base-autofix.test.sh diff --git a/.github/workflows/cmux-next-base-autofix.yml b/.github/workflows/cmux-next-base-autofix.yml new file mode 100644 index 000000000000..e69b44364559 --- /dev/null +++ b/.github/workflows/cmux-next-base-autofix.yml @@ -0,0 +1,118 @@ +name: cmux-next base autofix + +# After each cmux-next run on a feat-cmux-next push, repairs the base's +# mechanical reds in one commit on top of that push, so no pull request has to +# carry them: the generated files the Mac generated-files job rebuilt (its +# cmux-next-generated-patch artifact: action contracts, CI target graph) and +# cargo fmt in cmux-tui (scripts/cmux-next/base-autofix.sh). A frozen path +# (scripts/cmux-next/base-autofix-frozen.txt, including every workflow file) +# is reported and never written; missing cmux-tui tree inputs are reported +# (their fix edits cmux-tui-artifacts.yml); the app FFI pin stays with +# app-ffi-release.yml's app-ffi-repin pull request. +# +# Push identity: github-actions[bot] through this job's GITHUB_TOKEN +# (contents: write). No ruleset or branch protection applies to +# feat-cmux-next (2026-10-09). GITHUB_TOKEN cannot push workflow files, and its +# pushes start no workflow: the repair commit is verified by the next push's +# cmux-next run, which is enough for mechanical fixes, and the bot cannot loop. +# +# Dry run until the repo variable CMUX_NEXT_BASE_AUTOFIX is 1: it repairs and +# commits locally, and the summary shows what it would push. +on: + workflow_run: + workflows: [cmux-next] + types: [completed] + branches: [feat-cmux-next] + +permissions: {} + +concurrency: + group: cmux-next-base-autofix + cancel-in-progress: true + +jobs: + autofix: + name: autofix feat-cmux-next + if: ${{ github.repository == 'manaflow-ai/cmux' && github.event.workflow_run.event == 'push' && github.event.workflow_run.path == '.github/workflows/cmux-next.yml' && github.event.workflow_run.head_branch == 'feat-cmux-next' && github.event.workflow_run.head_repository.full_name == github.repository }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }} # ephemeral-required: holds a contents write token for feat-cmux-next; ephemeral Blacksmith VM + timeout-minutes: 20 + permissions: + actions: read + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + # Absent when the run regenerated nothing. + - name: Download the regenerated files + id: generated + continue-on-error: true + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: cmux-next-generated-patch + path: ${{ runner.temp }}/generated + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Set up pinned cmux-tui Rust + uses: ./.github/actions/setup-cmux-tui-rust + + - name: Repair + run: | + set -euo pipefail + generated=() + [[ -d "$RUNNER_TEMP/generated" ]] && generated=(--generated "$RUNNER_TEMP/generated") + scripts/cmux-next/base-autofix.sh ${generated[@]+"${generated[@]}"} "$GITHUB_WORKSPACE" | tee "$RUNNER_TEMP/autofix.txt" + { echo "### Base autofix at ${GITHUB_SHA:0:12}"; echo; sed 's/^/- /' "$RUNNER_TEMP/autofix.txt"; } >> "$GITHUB_STEP_SUMMARY" + + - name: Commit the repairs + id: commit + env: + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + if [[ -z "$(git status --porcelain)" ]]; then + echo "Nothing to repair at ${HEAD_SHA:0:12}." | tee -a "$GITHUB_STEP_SUMMARY" + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit --quiet -m "chore(next): base autofix at ${HEAD_SHA:0:12}" \ + -m "$(grep '^fixed: ' "$RUNNER_TEMP/autofix.txt" | sed 's/^fixed: /- /')" \ + -m "scripts/cmux-next/base-autofix.sh: $RUN_URL" + git show --stat --oneline HEAD | sed -n '1,40p' >> "$GITHUB_STEP_SUMMARY" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Push the repairs + if: steps.commit.outputs.changed == 'true' + env: + # The checkout keeps no credentials; the token is used only here. + TOKEN: ${{ github.token }} + ENABLED: ${{ vars.CMUX_NEXT_BASE_AUTOFIX == '1' }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + if [[ "$ENABLED" != true ]]; then + echo "Dry run (CMUX_NEXT_BASE_AUTOFIX is not 1): would push $(git rev-parse --short HEAD) to feat-cmux-next." | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + auth="$(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)" + echo "::add-mask::$auth" + remote="https://github.com/$GITHUB_REPOSITORY.git" + with_auth=(env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $auth") + current="$("${with_auth[@]}" git ls-remote "$remote" refs/heads/feat-cmux-next | awk '{print $1}')" + if [[ "$current" != "$HEAD_SHA" ]]; then + echo "feat-cmux-next moved to ${current:0:12} after ${HEAD_SHA:0:12}; the next run repairs it." | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # A plain push: it fails rather than overwrite a commit pushed meanwhile. + if ! "${with_auth[@]}" git push --quiet "$remote" HEAD:refs/heads/feat-cmux-next; then + echo "feat-cmux-next moved during the repair; the next run repairs it." | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "Pushed $(git rev-parse --short HEAD) to feat-cmux-next." | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/cmux-next.yml b/.github/workflows/cmux-next.yml index 2fee4cc08a95..f5c94da8e486 100644 --- a/.github/workflows/cmux-next.yml +++ b/.github/workflows/cmux-next.yml @@ -792,6 +792,7 @@ jobs: bash scripts/cmux-next/tests/check-test-notification-centers.test.sh bash scripts/cmux-next/tests/ratchet-tiers.test.sh bash scripts/cmux-next/tests/ratchet-debt-issue.test.sh + bash scripts/cmux-next/tests/base-autofix.test.sh bash scripts/ci/tests/cmux-tui-rust-check.test.sh bash scripts/ci/tests/package-test-lane-suite-args.test.sh bash scripts/ci/tests/package-test-lane-sdkroot.test.sh diff --git a/scripts/cmux-next/base-autofix-frozen.txt b/scripts/cmux-next/base-autofix-frozen.txt new file mode 100644 index 000000000000..11922fe34389 --- /dev/null +++ b/scripts/cmux-next/base-autofix-frozen.txt @@ -0,0 +1,14 @@ +# Paths the base autofix never writes (shell globs, repository-relative): the +# permanent freezes and the settings slot. A regenerated file under one is +# reported and left to its owner's pull request (Package.swift: the app FFI pin +# goes through app-ffi-release.yml's app-ffi-repin pull request). +cmux.xcodeproj/project.pbxproj +Packages/macOS/CmuxNext/Package.swift +plans/cmux-next/actions.md +plans/cmux-next/action-surfaces.json +schemas/settings/* +Packages/macOS/CmuxNext/Sources/CmuxNextSettings/* +webviews/src/pages/settings/* +docs/mdm/* +# The bot pushes with GITHUB_TOKEN, which cannot push workflow files. +.github/workflows/* diff --git a/scripts/cmux-next/base-autofix.sh b/scripts/cmux-next/base-autofix.sh new file mode 100755 index 000000000000..7982ebe36d57 --- /dev/null +++ b/scripts/cmux-next/base-autofix.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Repairs the mechanical reds of a feat-cmux-next checkout in place, without +# committing or pushing (cmux-next-base-autofix.yml does that): +# - generated files: copies DIR's files (the cmux-next generated files job's +# cmux-next-generated-patch artifact: the action contracts and the CI target +# graph regenerated on a Mac, at their repository paths) over the checkout; +# - cmux-tui tree inputs: reports each embed check_cmux_tui_tree_inputs.py +# finds missing (the fix edits a workflow file, which GITHUB_TOKEN cannot push); +# - the app FFI pin: reports a stale pin (Package.swift is frozen; the +# app-ffi-repin pull request carries it); +# - formatting: cargo fmt over the cmux-tui workspace (unless --no-fmt). +# A path matching base-autofix-frozen.txt is never written; it is reported. +# Prints one line per change ("fixed: ..."), skip ("skipped (frozen): ...") or +# repair left to a person ("left: ..."). +# +# Usage: scripts/cmux-next/base-autofix.sh [--generated DIR] [--no-fmt] REPO +set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +generated="" fmt=1 +while (( $# > 1 )); do + case "$1" in + --generated) generated="$2"; shift 2 ;; + --no-fmt) fmt=0; shift ;; + *) echo "usage: $0 [--generated DIR] [--no-fmt] REPO" >&2; exit 2 ;; + esac +done +repo="$(cd "${1:?usage: $0 [--generated DIR] [--no-fmt] REPO}" && pwd)" +frozen_list="$script_dir/base-autofix-frozen.txt" + +frozen() { # repository-relative path -> 0 when frozen + local pattern + while IFS= read -r pattern; do + [[ -z "$pattern" || "$pattern" == \#* ]] && continue + # shellcheck disable=SC2053 # the pattern is a glob + [[ "$1" == $pattern ]] && return 0 + done < "$frozen_list" + return 1 +} + +# 1. Generated files. +if [[ -n "$generated" && -d "$generated" ]]; then + while IFS= read -r -d '' file; do + rel="${file#"$generated"/}" + if cmp -s "$file" "$repo/$rel"; then continue; fi + if frozen "$rel"; then echo "skipped (frozen): $rel"; continue; fi + mkdir -p "$(dirname "$repo/$rel")" + cp "$file" "$repo/$rel" + echo "fixed: regenerated $rel" + done < <(find "$generated" -type f -print0 | sort -z) +fi + +# 2. cmux-tui tree inputs: reported only. Each must also be a pull_request_target +# path of cmux-tui-artifacts.yml, a workflow file GITHUB_TOKEN cannot push, and +# the two lists must change together. +missing="$(python3 -I "$script_dir/../ci/check_cmux_tui_tree_inputs.py" --root "$repo" 2>&1 >/dev/null | grep -E '^(blob|tree) ' || true)" +while IFS= read -r entry; do + [[ -n "$entry" ]] && echo "left: cmux-tui tree input $entry (add it to scripts/cmux-next/cmux-tui-tree-inputs.txt and .github/workflows/cmux-tui-artifacts.yml by hand)" +done <<<"$missing" + +# 3. The app FFI pin (reported only: Package.swift is frozen). +if [[ -f "$repo/Packages/macOS/CmuxNext/Package.swift" && -x "$repo/scripts/cmux-next/check-app-ffi-pin.sh" ]]; then + if ! (cd "$repo" && scripts/cmux-next/check-app-ffi-pin.sh >/dev/null 2>&1); then + if frozen Packages/macOS/CmuxNext/Package.swift; then + echo "skipped (frozen): Packages/macOS/CmuxNext/Package.swift (stale app FFI pin; the app-ffi-repin pull request carries it)" + else + echo "left: stale app FFI pin; repin with scripts/cmux-next/repin-app-ffi.sh once its release is published" + fi + fi +fi + +# 4. Formatting. +if (( fmt )) && [[ -f "$repo/cmux-tui/Cargo.toml" ]]; then + before="$(git -C "$repo" status --porcelain -- cmux-tui)" + (cd "$repo/cmux-tui" && cargo fmt --all) + after="$(git -C "$repo" status --porcelain -- cmux-tui)" + [[ "$before" == "$after" ]] || echo "fixed: cargo fmt in cmux-tui" +fi +exit 0 diff --git a/scripts/cmux-next/tests/base-autofix.test.sh b/scripts/cmux-next/tests/base-autofix.test.sh new file mode 100755 index 000000000000..963caa266d07 --- /dev/null +++ b/scripts/cmux-next/tests/base-autofix.test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# base-autofix.sh on a fixture repository: copies the regenerated files in +# (never a frozen path or a workflow file: GITHUB_TOKEN cannot push those), +# reports a missing cmux-tui tree input without writing it (its second list is +# a workflow's paths), and changes nothing when everything is current. No +# network, no git push. +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)" +FIX="$ROOT_DIR/scripts/cmux-next/base-autofix.sh" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/cmux-base-autofix.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT +fail() { echo "FAIL: $*" >&2; cat "$tmp/out" >&2 2>/dev/null || true; exit 1; } + +repo="$tmp/repo"; gen="$tmp/generated" +mkdir -p "$repo/cmux-tui/crates/x/src" "$repo/scripts/cmux-next" "$repo/.github/workflows" \ + "$repo/schemas/new" "$repo/Packages/macOS/CmuxNext" "$repo/plans/cmux-next" \ + "$gen/Packages/macOS/CmuxNext" "$gen/plans/cmux-next" "$gen/.github/workflows" +printf '[package]\nname = "x"\n' > "$repo/cmux-tui/crates/x/Cargo.toml" +printf 'pub const V: &str = include_str!("../../../../schemas/new/v.json");\n' > "$repo/cmux-tui/crates/x/src/lib.rs" +echo '{}' > "$repo/schemas/new/v.json" +printf 'tree cmux-tui\n' > "$repo/scripts/cmux-next/cmux-tui-tree-inputs.txt" +cat > "$repo/.github/workflows/cmux-tui-artifacts.yml" <<'YML' +on: + pull_request_target: + branches: [feat-cmux-next] + paths: + - "cmux-tui/**" + workflow_dispatch: +YML +echo old > "$repo/Packages/macOS/CmuxNext/ci-target-graph.json" +echo old > "$repo/plans/cmux-next/actions.md" +echo new > "$gen/Packages/macOS/CmuxNext/ci-target-graph.json" +echo new > "$gen/plans/cmux-next/actions.md" +echo 'on: push' > "$gen/.github/workflows/cmux-tui-artifacts.yml" +git -C "$repo" init -q && git -C "$repo" add -A && git -C "$repo" -c user.name=t -c user.email=t@t commit -qm fixture + +bash "$FIX" --no-fmt --generated "$gen" "$repo" > "$tmp/out" 2>&1 || fail "the fixer must succeed" +[[ "$(cat "$repo/Packages/macOS/CmuxNext/ci-target-graph.json")" == new ]] || fail "a regenerated file must be copied in" +[[ "$(cat "$repo/plans/cmux-next/actions.md")" == old ]] || fail "a frozen path must never be written" +grep -q '^skipped (frozen): plans/cmux-next/actions.md' "$tmp/out" || fail "a skipped frozen path must be reported" +grep -q '^skipped (frozen): .github/workflows/cmux-tui-artifacts.yml' "$tmp/out" || fail "a regenerated workflow file must be skipped and reported" +grep -q '^left: cmux-tui tree input blob schemas/new/v.json' "$tmp/out" || fail "a missing tree input must be reported" +grep -q 'schemas/new' "$repo/scripts/cmux-next/cmux-tui-tree-inputs.txt" "$repo/.github/workflows/cmux-tui-artifacts.yml" && fail "a missing tree input must not be written to either list" + +git -C "$repo" add -A && git -C "$repo" -c user.name=t -c user.email=t@t commit -qm fixed +bash "$FIX" --no-fmt --generated "$gen" "$repo" > "$tmp/out" 2>&1 || fail "a second run must succeed" +[[ -z "$(git -C "$repo" status --porcelain)" ]] || fail "a current tree must stay unchanged" +echo "base autofix: ok" From 7af5d8bca1f20f3d5cf97e984c974879da9b493e Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:26:30 -0700 Subject: [PATCH 05/15] fix(release-rails): resolve capabilities re-exported from another cmux-tui crate (#18913) 36c60c86bbba moved the image paste spool into cmux-tui-image-paste, and cmux-tui-core's lib.rs re-exports it (use cmux_tui_image_paste:: image_paste;). The image staleness guard (8e23f8a61780, cx-4l51) only looked for crate::image_paste under cmux-tui-core, found 9 constants named CAPABILITY and failed, so release rails is red on every PR and on nightly-next. A crate:: path whose first segment lib.rs re-exports from a workspace crate now also names that crate's module file. No bead (repairs the cx-4l51 guard). Co-authored-by: Claude Opus 5.5 --- scripts/cmux-next/release/image-staleness.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/scripts/cmux-next/release/image-staleness.ts b/scripts/cmux-next/release/image-staleness.ts index 21cafb668bac..afa3e44b5276 100644 --- a/scripts/cmux-next/release/image-staleness.ts +++ b/scripts/cmux-next/release/image-staleness.ts @@ -119,13 +119,24 @@ const constIndex = (reader: SourceReader): Map): Array => { +/** The module files a Rust path (`crate::a::b`, `a::b` relative to server) can name. A + * `crate::a` that the core's lib.rs re-exports from another workspace crate + * (`use cmux_tui_image_paste::image_paste;`) names that crate's module file. */ +const moduleFiles = (segments: Array, reader: SourceReader): Array => { const rel = segments.filter((s) => s !== "crate" && s !== "super" && s !== "self") if (rel.length === 0) return [] const p = rel.join("/") const roots = segments[0] === "crate" ? [CORE] : [`${CORE}/server`, CORE] - return roots.flatMap((r) => [`${r}/${p}.rs`, `${r}/${p}/mod.rs`]) + const files = roots.flatMap((r) => [`${r}/${p}.rs`, `${r}/${p}/mod.rs`]) + if (segments[0] === "crate") { + const lib = stripComments(reader.read(`${CORE}/lib.rs`) ?? "") + const reexport = lib.match(new RegExp(`\\buse\\s+(\\w+)::${rel[0]}\\s*;`)) + if (reexport && !["crate", "self", "super"].includes(reexport[1]!)) { + const src = `${CRATES}/${reexport[1]!.replace(/_/g, "-")}/src` + files.push(`${src}/${p}.rs`, `${src}/${p}/mod.rs`) + } + } + return files } const resolveToken = (token: string, index: Map>, reader: SourceReader, depth = 0): string => { @@ -135,7 +146,7 @@ const resolveToken = (token: string, index: Map) => [...new Set(list.map((c) => c.value))] - const files = moduleFiles(segments) + const files = moduleFiles(segments, reader) if (files.length) { const inModule = found.filter((c) => files.includes(c.file) || files.some((f) => f.endsWith("/mod.rs") && c.file.startsWith(f.slice(0, -"mod.rs".length))) || files.some((f) => c.file.startsWith(f.slice(0, -".rs".length) + "/"))) if (distinct(inModule).length === 1) return inModule[0]!.value From 1e0c0bbcedbb3b205f9dfe75ffcc8c409d263d06 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:28:24 -0700 Subject: [PATCH 06/15] ci: format staged files before commit (#18808) * ci: format staged files in the trusted pre-commit hook * ci: guard formatter scope for staged files * ci: scope Rust formatter guard to its package * ci: use direct rustfmt and skip merge formatting * ci: pass the cmux-tui Rust edition to rustfmt --- CONTRIBUTING.md | 7 ++ scripts/git-hooks/pre-commit | 151 +++++++++++++++++++++++++++++++---- scripts/install-git-hooks.sh | 4 +- 3 files changed, 145 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7cf957c9a41..f8b623b6da11 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,13 @@ CMUX_DEV_BACKEND_MODE=local ./scripts/reload.sh --tag my-feature - After pulling changes to setup or the merge drivers, rerun `./scripts/install-git-hooks.sh`. With a custom `core.hooksPath` it prints the `pre-commit` and `post-merge` lines to add yourself; wire both. +- The tracked pre-commit hook formats only staged webview files with `vp fmt` + and staged `cmux-tui` Rust files with direct `rustfmt`, then re-stages + formatter output. It never builds, and skips all formatters during a merge + commit. Install it in every worktree with + `./scripts/install-git-hooks.sh` (the configured hooks path is shared by the + worktrees in a clone). Stage or stash edits in the affected file or Rust + package before committing so the hook cannot include unstaged changes. Team members with a Stack account can also sign DEBUG builds in and attach an iOS build: see [team dev setup](docs/team-dev-setup.md). diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 44d6e0296d3b..79279d9cc7e7 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -36,25 +36,146 @@ if git diff --cached --name-only --diff-filter=ACMRT | grep -Eq '^(tests/test_[^ fi fi -# Only normalize when the pbxproj is part of this commit. -if ! git diff --cached --name-only --diff-filter=ACMRT | grep -Fxq "$PBXPROJ"; then +# A merge commit combines base-owned files with the staged result. Do not +# reformat that result while Git is resolving the merge; formatters run on the +# authored files in the follow-up commit instead. +MERGE_HEAD_PATH="$(git rev-parse --git-path MERGE_HEAD)" +if [[ -f "$MERGE_HEAD_PATH" ]]; then + echo "pre-commit: merge in progress; skipping formatters" exit 0 fi -# Refuse to normalize when the working-tree pbxproj has unstaged changes. -# Otherwise the `git add` at the end would silently stage hunks the -# user deliberately left out of this commit. -if ! git diff --quiet -- "$PBXPROJ"; then - echo "pre-commit: $PBXPROJ has unstaged changes; cannot safely normalize." >&2 - echo "Either stage the whole file (git add $PBXPROJ) or stash the unstaged hunks before committing." >&2 - exit 1 +# Only normalize when the pbxproj is part of this commit. +if git diff --cached --name-only --diff-filter=ACMRT | grep -Fxq "$PBXPROJ"; then + # Refuse to normalize when the working-tree pbxproj has unstaged changes. + # Otherwise the `git add` at the end would silently stage hunks the + # user deliberately left out of this commit. + if ! git diff --quiet -- "$PBXPROJ"; then + echo "pre-commit: $PBXPROJ has unstaged changes; cannot safely normalize." >&2 + echo "Either stage the whole file (git add $PBXPROJ) or stash the unstaged hunks before committing." >&2 + exit 1 + fi + + "$PYTHON3_BIN" -I "$PBX_NORMALIZER" "$PBXPROJ" + + # If the normalizer rewrote the file, restage so the commit captures + # the normalized form. (Working tree had no unstaged changes per the + # guard above, so anything that changed is purely from normalization.) + if ! git diff --quiet -- "$PBXPROJ"; then + git add "$PBXPROJ" + fi fi -"$PYTHON3_BIN" -I "$PBX_NORMALIZER" "$PBXPROJ" +# Keep the formatters on the fast path: only invoke them for files that are in +# this commit, and never build or inspect the rest of the checkout. A formatter +# rewrites its input in place, so refuse a file with unstaged edits rather than +# accidentally adding those edits to this commit. +STAGED_WEB_PATHS=() +STAGED_RUST_PATHS=() +STAGED_SWIFT_PATHS=() +while IFS= read -r -d '' path; do + if ! git diff --quiet -- "$path"; then + case "$path" in + webviews/*|cmux-tui/*|*.swift) + echo "pre-commit: $path has unstaged changes; stage or stash them before formatting." >&2 + exit 1 + ;; + esac + fi + + case "$path" in + webviews/*) + case "$path" in + *.js|*.jsx|*.ts|*.tsx|*.mjs|*.cjs|*.mts|*.cts|*.css|*.scss|*.html|*.json|*.md|*.mdx) + STAGED_WEB_PATHS+=("${path#webviews/}") + ;; + esac + ;; + cmux-tui/*) + if [[ "$path" == *.rs ]]; then + STAGED_RUST_PATHS+=("$path") + fi + ;; + *.swift) + STAGED_SWIFT_PATHS+=("$path") + ;; + esac +done < <(git diff --cached --name-only --diff-filter=ACMRT -z) -# If the normalizer rewrote the file, restage so the commit captures -# the normalized form. (Working tree had no unstaged changes per the -# guard above, so anything that changed is purely from normalization.) -if ! git diff --quiet -- "$PBXPROJ"; then - git add "$PBXPROJ" +if (( ${#STAGED_WEB_PATHS[@]} > 0 )); then + VP_BIN="$(command -v vp || true)" + if [[ -z "$VP_BIN" && -x "$REPO_ROOT/webviews/node_modules/.bin/vp" ]]; then + VP_BIN="$REPO_ROOT/webviews/node_modules/.bin/vp" + fi + if [[ -z "$VP_BIN" ]]; then + echo "pre-commit: vp is unavailable; skipping webviews formatting (CI will run vp check)." >&2 + else + ( + cd "$REPO_ROOT/webviews" + "$VP_BIN" fmt "${STAGED_WEB_PATHS[@]}" + ) + git add -- "${STAGED_WEB_PATHS[@]/#/webviews/}" + fi +fi + +if (( ${#STAGED_RUST_PATHS[@]} > 0 )); then + # rustfmt can follow `mod` declarations from a staged module and rewrite + # its children. Refuse that case within the affected Cargo package rather + # than touching an unstaged Rust file in the worktree. + RUST_PACKAGE_ROOTS=() + for path in "${STAGED_RUST_PATHS[@]}"; do + package_dir="${path%/*}" + package_root="" + while [[ "$package_dir" == cmux-tui* && -n "$package_dir" ]]; do + if [[ -f "$REPO_ROOT/$package_dir/Cargo.toml" ]]; then + package_root="$package_dir" + break + fi + if [[ "$package_dir" == */* ]]; then + package_dir="${package_dir%/*}" + else + package_dir="" + fi + done + if [[ -n "$package_root" ]]; then + RUST_PACKAGE_ROOTS+=("$package_root") + fi + done + for package_root in "${RUST_PACKAGE_ROOTS[@]}"; do + if { + git diff --name-only --diff-filter=ACMRT -- "$package_root" + git ls-files --others --exclude-standard -- "$package_root" + } | grep -Eq '\.rs$'; then + echo "pre-commit: $package_root has unstaged Rust edits; stage or stash them before formatting." >&2 + exit 1 + fi + done + RUSTFMT_BIN="$(command -v rustfmt || true)" + if [[ -z "$RUSTFMT_BIN" ]]; then + echo "pre-commit: rustfmt is unavailable; skipping cmux-tui formatting (CI will run cargo fmt --check)." >&2 + else + RUSTFMT_PATHS=() + for path in "${STAGED_RUST_PATHS[@]}"; do + RUSTFMT_PATHS+=("${path#cmux-tui/}") + done + ( + cd "$REPO_ROOT/cmux-tui" + "$RUSTFMT_BIN" --edition 2024 --config-path "$REPO_ROOT/cmux-tui/rustfmt.toml" "${RUSTFMT_PATHS[@]}" + ) + git add -- "${STAGED_RUST_PATHS[@]}" + fi +fi + +# cmux currently has no Swift-format configuration. Keep this branch ready for +# the day one is added without making a formatter a setup prerequisite today. +if (( ${#STAGED_SWIFT_PATHS[@]} > 0 )) && [[ -f "$REPO_ROOT/.swift-format" ]]; then + SWIFT_FORMAT_BIN="$(command -v swift-format || true)" + if [[ -z "$SWIFT_FORMAT_BIN" ]]; then + echo "pre-commit: swift-format is unavailable; skipping Swift formatting (CI will report it)." >&2 + else + for path in "${STAGED_SWIFT_PATHS[@]}"; do + "$SWIFT_FORMAT_BIN" format --in-place --configuration "$REPO_ROOT/.swift-format" "$path" + git add -- "$path" + done + fi fi diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index 4a9cfa36da0f..be29058dfc47 100755 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -69,8 +69,8 @@ printf -v TRUSTED_HOOKS_CONFIG 'git config core.hooksPath %q' "$TRUSTED_HOOK_DIR warn_manual_wiring() { local hooks_dir="$1" { - echo "To run cmux's tracked pre-commit checks (pbxproj normalization, test" - echo "registration) alongside your hooks, add this line to $hooks_dir/pre-commit" + echo "To run cmux's tracked pre-commit checks (formatting, pbxproj normalization," + echo "test registration) alongside your hooks, add this line to $hooks_dir/pre-commit" echo "(create it with a #!/bin/sh line and chmod +x if it does not exist):" echo "" echo " $TRUSTED_PRE_COMMIT" From 08cd1082baf38e66e08c3c536d630fb0a8464f78 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:28:27 -0700 Subject: [PATCH 07/15] ci: check rustfmt on the cmux-tui packages a PR touches (#18800) * test(ci): red test, PRs check rustfmt on the cmux-tui packages they touch PR CI never runs cmux-tui.yml (dispatch only), so #18706 landed unformatted Rust and broke base cargo fmt --check. These tests pin a helper that runs cargo fmt --check once per touched package, checks every package when a rustfmt config changes or there is no base, and names each unformatted package. Co-Authored-By: Claude Opus 5.5 * ci: check rustfmt on the cmux-tui packages a PR touches PR CI never runs cmux-tui.yml (dispatch only), so #18706 landed unformatted Rust and broke base cargo fmt --check. cmux-tui-spec.yml already runs on every PR and push touching cmux-tui/** on hosted Linux; its new rustfmt job runs cmux-tui-fmt-changed.py, which maps each changed .rs file or Cargo.toml to its nearest tracked package and runs cargo fmt --check on it with the pinned toolchain. No build and no dependency download. A rustfmt config or toolchain change, or no base, checks every package; vendored crates and deleted packages are skipped. A PR compares with its merge commit's first parent, a push with the commit before it. Co-Authored-By: Claude Opus 5.5 * style(cmux-tui): rustfmt acpmux's draft tests and the relay circuit The new rustfmt job's full run found two base packages that fail cargo fmt --check: acpmux, from #18706's draft tests and their module order, and the Cloudflare relay's circuit.rs. Formatting only, exactly the hosted rustfmt diff. Co-Authored-By: Claude Opus 5.5 * style(cmux-tui): leave the relay circuit to its own repair Touching the Cloudflare relay runs its lane, whose npm audit is red on base (sharp and undici advisories through wrangler, fixed only by a wrangler bump outside the stated range). That is the relay's own repair, so this PR keeps acpmux's formatting only. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/scripts/cmux-tui-fmt-changed.py | 106 ++++++++++++ .github/scripts/test_cmux_tui_fmt_changed.py | 170 +++++++++++++++++++ .github/workflows/cmux-tui-spec.yml | 45 +++++ 3 files changed, 321 insertions(+) create mode 100755 .github/scripts/cmux-tui-fmt-changed.py create mode 100644 .github/scripts/test_cmux_tui_fmt_changed.py diff --git a/.github/scripts/cmux-tui-fmt-changed.py b/.github/scripts/cmux-tui-fmt-changed.py new file mode 100755 index 000000000000..7cb0da3bb4f8 --- /dev/null +++ b/.github/scripts/cmux-tui-fmt-changed.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Run `cargo fmt --check` on each cmux-tui package a change touches. + +Usage: cmux-tui-fmt-changed.py [--repo ROOT] [--base SHA] + +PR CI never runs cmux-tui.yml (dispatch only), so #18706 landed unformatted +Rust and broke base `cargo fmt --check`. This checks the tracked packages +under cmux-tui/ owning a changed .rs file or Cargo.toml between --base and +HEAD; with no usable base, or when a rustfmt config or the toolchain pin +changes, it checks every tracked package. Vendored crates (cmux-tui/vendor/) +keep their upstream formatting and are never checked. rustfmt reads metadata only +(`--no-deps`), so nothing builds and no dependency downloads. Every touched +package is checked even after one fails, and each red one is named. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path, PurePosixPath +import subprocess +import sys + + +ROOT = "cmux-tui" +VENDOR = f"{ROOT}/vendor/" +EVERYTHING = frozenset({"rustfmt.toml", ".rustfmt.toml", "rust-toolchain.toml"}) + + +def git(repo: Path, *args: str) -> str: + return subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, text=True).stdout + + +def packages(repo: Path) -> set[str]: + """Tracked package directories (a Cargo.toml with a [package] table) under cmux-tui/.""" + found = set() + for path in git(repo, "ls-files", "-z", "--", ROOT).split("\0"): + if PurePosixPath(path).name != "Cargo.toml" or path.startswith(VENDOR) or not (repo / path).is_file(): + continue + if any(line.strip() == "[package]" for line in (repo / path).read_text(encoding="utf-8").splitlines()): + found.add(str(PurePosixPath(path).parent)) + return found + + +def changed(repo: Path, base: str) -> list[str] | None: + """Paths under cmux-tui/ changed since `base`, or None when there is no usable base.""" + if not base or set(base) == {"0"}: + return None + try: + git(repo, "cat-file", "-e", f"{base}^{{commit}}") + except subprocess.CalledProcessError: + print(f"cmux-tui-fmt-changed: base {base} is not in this checkout; checking every package") + return None + return [path for path in git(repo, "diff", "--name-only", "-z", base, "HEAD", "--", ROOT).split("\0") if path] + + +def manifest_dirs(repo: Path, base: str) -> set[str]: + """Directories with a Cargo.toml at `base` or tracked now: a deleted package's files stop there.""" + listed = git(repo, "ls-tree", "-r", "-z", "--name-only", base, "--", ROOT).split("\0") + listed += git(repo, "ls-files", "-z", "--", ROOT).split("\0") + return {str(PurePosixPath(path).parent) for path in listed if PurePosixPath(path).name == "Cargo.toml"} + + +def touched(paths: list[str], known: set[str], boundaries: set[str]) -> set[str]: + """The current packages owning changed Rust sources or manifests (the nearest Cargo.toml up).""" + owners = set() + for path in paths: + pure = PurePosixPath(path) + if path.startswith(VENDOR) or (pure.suffix != ".rs" and pure.name != "Cargo.toml"): + continue + owner = next((str(parent) for parent in pure.parents if str(parent) in boundaries), None) + if owner in known: + owners.add(owner) + return owners + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo", type=Path) + parser.add_argument("--base", default="") + args = parser.parse_args(argv) + repo = (args.repo or Path(git(Path.cwd(), "rev-parse", "--show-toplevel").strip())).resolve() + known = packages(repo) + paths = changed(repo, args.base.strip()) + if paths is None or any(PurePosixPath(path).name in EVERYTHING for path in paths): + selected = known + else: + selected = touched(paths, known, manifest_dirs(repo, args.base.strip())) + if not selected: + print("cmux-tui-fmt-changed: no cmux-tui package changed") + return 0 + red = [] + for package in sorted(selected): + manifest = repo / package / "Cargo.toml" + print(f"cargo fmt --check --manifest-path {package}/Cargo.toml", flush=True) + # From cmux-tui/ so rustup uses its rust-toolchain.toml pin. + if subprocess.run(["cargo", "fmt", "--check", "--manifest-path", str(manifest)], cwd=repo / ROOT).returncode: + red.append(package) + for package in red: + print(f"::error title=cmux-tui rustfmt::{package} is not rustfmt-clean; " + f"run `cargo fmt --manifest-path {package}/Cargo.toml` from the repository root and commit the result") + print(f"cmux-tui-fmt-changed: {len(selected) - len(red)}/{len(selected)} packages rustfmt-clean") + return 1 if red else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_cmux_tui_fmt_changed.py b/.github/scripts/test_cmux_tui_fmt_changed.py new file mode 100644 index 000000000000..aaa3fd77b82c --- /dev/null +++ b/.github/scripts/test_cmux_tui_fmt_changed.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Behavior tests for the changed-crate cmux-tui rustfmt check. + +PR CI never ran cmux-tui.yml (dispatch only), so #18706 landed unformatted Rust +and broke base `cargo fmt --check`. cmux-tui-fmt-changed.py runs +`cargo fmt --check` on each package a change touches; these tests drive it on +a scratch repository with a fake cargo that records its calls. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = Path(__file__).with_name("cmux-tui-fmt-changed.py") +FAKE_CARGO = """#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$CARGO_LOG" +for arg in "$@"; do + if [[ "$arg" == *"/${FAIL_PACKAGE:-none}/Cargo.toml" ]]; then + echo "Diff in $arg" + exit 1 + fi +done +""" + + +class FmtChangedTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = Path(tempfile.mkdtemp()) + self.repo = self.tmp / "repo" + # The workspace root is a package too, as in the real tree. + self.write("cmux-tui/Cargo.toml", '[package]\nname = "root"\n\n[workspace]\nmembers = ["crates/a", "crates/b"]\n') + self.write("cmux-tui/src/main.rs", "fn main() {}\n") + self.write("cmux-tui/rustfmt.toml", "max_width = 100\n") + self.write("cmux-tui/crates/a/Cargo.toml", '[package]\nname = "a"\n') + self.write("cmux-tui/crates/a/src/lib.rs", "pub fn a() {}\n") + self.write("cmux-tui/crates/a/src/deep/mod.rs", "pub fn d() {}\n") + self.write("cmux-tui/crates/b/Cargo.toml", '[package]\nname = "b"\n') + self.write("cmux-tui/crates/b/src/lib.rs", "pub fn b() {}\n") + # Its own workspace, outside the cmux-tui workspace members. + self.write("cmux-tui/crates/ffi/Cargo.toml", '[package]\nname = "ffi"\n\n[workspace]\n') + self.write("cmux-tui/crates/ffi/src/lib.rs", "pub fn f() {}\n") + self.write("cmux-tui/target/debug/build/x/Cargo.toml", '[package]\nname = "built"\n') + # Vendored upstream code keeps upstream formatting. + self.write("cmux-tui/vendor/up/Cargo.toml", '[package]\nname = "up"\n') + self.write("cmux-tui/vendor/up/src/lib.rs", "pub fn u() {}\n") + self.write("README.md", "readme\n") + (self.repo / ".gitignore").write_text("cmux-tui/target/\n") + self.git("init", "-q") + self.git("add", ".") + self.git("commit", "-qm", "base") + self.base = self.git("rev-parse", "HEAD").strip() + bin_dir = self.tmp / "bin" + bin_dir.mkdir() + cargo = bin_dir / "cargo" + cargo.write_text(FAKE_CARGO) + cargo.chmod(0o755) + self.log = self.tmp / "cargo.log" + self.env = {**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}", "CARGO_LOG": str(self.log)} + + def write(self, path: str, text: str) -> None: + target = self.repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text) + + def git(self, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(self.repo), "-c", "user.name=t", "-c", "user.email=t@t", + "-c", "commit.gpgsign=false", *args], + check=True, capture_output=True, text=True, + ).stdout + + def change(self, path: str, text: str = "pub fn changed() {}\n") -> None: + self.write(path, text) + self.git("add", "-A") + self.git("commit", "-qm", "change") + + def run_check(self, *args: str, fail: str | None = None) -> subprocess.CompletedProcess[str]: + env = dict(self.env) + if fail: + env["FAIL_PACKAGE"] = fail + return subprocess.run( + [sys.executable, str(SCRIPT), "--repo", str(self.repo), *args], + env=env, capture_output=True, text=True, + ) + + def checked(self) -> list[str]: + if not self.log.exists(): + return [] + manifests = [] + for line in self.log.read_text().splitlines(): + words = line.split() + self.assertEqual(words[:2], ["fmt", "--check"], line) + manifests.append(str(Path(words[words.index("--manifest-path") + 1]).relative_to(self.repo))) + return manifests + + def test_a_changed_rust_file_checks_only_its_package(self) -> None: + self.change("cmux-tui/crates/a/src/deep/mod.rs") + result = self.run_check("--base", self.base) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.checked(), ["cmux-tui/crates/a/Cargo.toml"]) + + def test_each_touched_package_is_checked_once(self) -> None: + self.write("cmux-tui/crates/a/src/lib.rs", "pub fn a2() {}\n") + self.write("cmux-tui/crates/a/src/deep/mod.rs", "pub fn d2() {}\n") + self.change("cmux-tui/crates/ffi/src/lib.rs") + result = self.run_check("--base", self.base) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(sorted(self.checked()), ["cmux-tui/crates/a/Cargo.toml", "cmux-tui/crates/ffi/Cargo.toml"]) + + def test_an_unformatted_package_fails_and_names_it(self) -> None: + self.change("cmux-tui/crates/b/src/lib.rs") + result = self.run_check("--base", self.base, fail="b") + self.assertNotEqual(result.returncode, 0) + self.assertIn("cmux-tui/crates/b", result.stdout + result.stderr) + self.assertIn("cargo fmt", result.stdout + result.stderr) + + def test_one_red_package_does_not_hide_the_others(self) -> None: + self.write("cmux-tui/crates/a/src/lib.rs", "pub fn a2() {}\n") + self.change("cmux-tui/crates/b/src/lib.rs") + result = self.run_check("--base", self.base, fail="a") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(sorted(self.checked()), ["cmux-tui/crates/a/Cargo.toml", "cmux-tui/crates/b/Cargo.toml"]) + + def test_a_change_outside_rust_packages_checks_nothing(self) -> None: + self.change("README.md", "new readme\n") + result = self.run_check("--base", self.base) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.checked(), []) + + def test_a_rustfmt_config_change_checks_every_package(self) -> None: + self.change("cmux-tui/rustfmt.toml", "max_width = 90\n") + result = self.run_check("--base", self.base) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(sorted(self.checked()), [ + "cmux-tui/Cargo.toml", "cmux-tui/crates/a/Cargo.toml", "cmux-tui/crates/b/Cargo.toml", + "cmux-tui/crates/ffi/Cargo.toml"]) + + def test_no_base_checks_every_tracked_package(self) -> None: + for base in ([], ["--base", ""], ["--base", "0" * 40]): + with self.subTest(base=base): + self.log.unlink(missing_ok=True) + result = self.run_check(*base) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + # Ignored build output is never a package to check. + self.assertEqual(sorted(self.checked()), [ + "cmux-tui/Cargo.toml", "cmux-tui/crates/a/Cargo.toml", "cmux-tui/crates/b/Cargo.toml", + "cmux-tui/crates/ffi/Cargo.toml"]) + + def test_a_vendored_package_is_never_checked(self) -> None: + self.change("cmux-tui/vendor/up/src/lib.rs") + result = self.run_check("--base", self.base) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.checked(), []) + + def test_a_deleted_package_is_skipped(self) -> None: + self.git("rm", "-rq", "cmux-tui/crates/b") + self.git("commit", "-qm", "drop b") + result = self.run_check("--base", self.base) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.checked(), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/cmux-tui-spec.yml b/.github/workflows/cmux-tui-spec.yml index 292ffe4737b7..dd7af0ae02b8 100644 --- a/.github/workflows/cmux-tui-spec.yml +++ b/.github/workflows/cmux-tui-spec.yml @@ -11,10 +11,16 @@ on: paths: - "cmux-tui/**" - ".github/workflows/cmux-tui-spec.yml" + - ".github/scripts/cmux-tui-fmt-changed.py" + - ".github/scripts/test_cmux_tui_fmt_changed.py" + - ".github/actions/setup-cmux-tui-rust/**" pull_request: paths: - "cmux-tui/**" - ".github/workflows/cmux-tui-spec.yml" + - ".github/scripts/cmux-tui-fmt-changed.py" + - ".github/scripts/test_cmux_tui_fmt_changed.py" + - ".github/actions/setup-cmux-tui-rust/**" workflow_dispatch: concurrency: @@ -61,3 +67,42 @@ jobs: - name: Check generated SDK wire layers run: python3 cmux-tui/bindings/codegen/generate.py --check + + # PR CI never runs cmux-tui.yml (dispatch only), so #18706 landed unformatted + # Rust and broke base `cargo fmt --check`. rustfmt alone on the packages the + # change touches: no build and no dependency download. + rustfmt: + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-4vcpu-ubuntu-2404' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Test the changed-package rustfmt check + run: python3 .github/scripts/test_cmux_tui_fmt_changed.py + + - name: Set up pinned Rust + uses: ./.github/actions/setup-cmux-tui-rust + + # Pull requests compare with the base their merge commit was made on + # (its first parent; the event's base.sha can be older), pushes with the + # commit before them; a dispatch (or a new branch) checks every package. + - name: Fetch the comparison base + id: base + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'push' && github.event.before || '' }} + run: | + if [[ "$EVENT_NAME" == pull_request ]]; then + BASE_SHA=$(git cat-file -p HEAD | awk '/^parent /{print $2; exit}') + fi + if [[ -n "$BASE_SHA" && "$BASE_SHA" != 0000000000000000000000000000000000000000 ]]; then + git fetch --no-tags --depth=1 origin "$BASE_SHA" || echo "::warning::could not fetch $BASE_SHA; checking every package" + fi + echo "sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + + - name: cargo fmt --check on changed cmux-tui packages + env: + BASE_SHA: ${{ steps.base.outputs.sha }} + run: python3 .github/scripts/cmux-tui-fmt-changed.py --base "$BASE_SHA" From 7a52911850fdf9b2ae0aa289cfba7538c9420b94 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:28:32 -0700 Subject: [PATCH 08/15] ci: add mini-first Swift canary for cmux-next (#18781) * ci: add mini-first Swift canary for cmux-next * ci: remove unit tests from Swift canary change --- .github/workflows/cmux-next.yml | 76 ++++++++++++++++++++++++++++++++- docs/ci/cmux-next-tiers.md | 10 +++++ scripts/ci/cmux_next_route.py | 43 ++++++++++++++++++- scripts/gh-merge-green | 1 + 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cmux-next.yml b/.github/workflows/cmux-next.yml index f5c94da8e486..b2cfad0f236d 100644 --- a/.github/workflows/cmux-next.yml +++ b/.github/workflows/cmux-next.yml @@ -405,6 +405,8 @@ jobs: pull-requests: read outputs: native: ${{ steps.route.outputs.native }} + swift_canary: ${{ steps.route.outputs.swift_canary }} + swift_canary_targets: ${{ steps.route.outputs.swift_canary_targets }} macos: ${{ steps.route.outputs.macos }} scheme: ${{ steps.route.outputs.scheme }} generated: ${{ steps.route.outputs.generated }} @@ -598,7 +600,7 @@ jobs: env: ROUTE_TOKEN: ${{ steps.route-token.outputs.token }} SIDE_LABEL: ${{ env.CMUX_NEXT_SIDE_ROUTE }} - JOBS: cmux-scheme-compile release-compile swift-test daemon-test generated-files + JOBS: swift-canary cmux-scheme-compile release-compile swift-test daemon-test generated-files run: | python3 scripts/ci/side_lane_placement.py # The trusted checkout may still be on a base branch that predates @@ -1089,6 +1091,72 @@ jobs: fi exit 1 + # Small package-only Swift edits get a mini-first type-check/build canary. + # This uses the same placement and linked SwiftPM scratch as the other Mac + # jobs, so a warm mini can answer in roughly 1-2 minutes. It deliberately + # excludes the app host, package manifest, resources and toolchain inputs. + swift-canary: + needs: [path_route, macos-placement, push-head-preflight] + if: ${{ !cancelled() && needs.path_route.outputs.swift_canary == 'true' && needs.push-head-preflight.outputs.current == 'true' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/feat-cmux-next' || startsWith(github.ref, 'refs/heads/next-batch/')) }} + name: cmux-next Swift canary + concurrency: + group: ${{ github.event_name == 'push' && format('cmux-next-push-{0}-swift-canary', github.ref) || format('cmux-next-run-{0}-swift-canary', github.run_id) }} + cancel-in-progress: false + runs-on: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + permissions: + contents: read + timeout-minutes: 20 + env: + CMUX_NEXT_RUNNER: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + SWIFT_CANARY_TARGETS: ${{ needs.path_route.outputs.swift_canary_targets }} + steps: + - name: Clear stale git locks (self-hosted reused workspace) + shell: bash + run: | + ws="${GITHUB_WORKSPACE:-$PWD}" + rm -f "$ws/.git/index.lock" 2>/dev/null || true + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + submodules: false + fetch-depth: 2 + + - name: Drop stale submodule checkouts (self-hosted reused workspace) + run: scripts/ci/reset-stale-submodules.sh + + # Checkout clean removes package .build directories. Link a per-mini + # scratch directory so successive canaries reuse its SwiftPM artifacts. + - name: Keep Swift package builds on this owned Mac + if: ${{ contains(runner.name, '-glaeda') }} + continue-on-error: true + run: python3 scripts/ci/owned_spm_scratch.py link "$GITHUB_WORKSPACE" + + - name: Select Xcode + run: ./scripts/select-ci-xcode.sh + + - name: Type-check changed CmuxNext targets + working-directory: Packages/macOS/CmuxNext + env: + SWIFT_CANARY_TARGETS: ${{ needs.path_route.outputs.swift_canary_targets }} + run: | + set -euo pipefail + read -r -a targets <<< "$SWIFT_CANARY_TARGETS" + ((${#targets[@]} > 0)) + started=$(date +%s) + for target in "${targets[@]}"; do + echo "Canary target: $target" + swift build --configuration debug --target "$target" + done + elapsed=$(( $(date +%s) - started )) + { + echo "### Swift canary" + echo + echo "Targets: ${targets[*]}" + echo "Elapsed: ${elapsed}s" + echo "This is the PR gate for package-only CmuxNext Swift edits. Release remains the base-push backstop and may report advisory failures on this PR class." + } >> "$GITHUB_STEP_SUMMARY" + # The CmuxNext test targets the change reaches: path_route's swift_filter # (an empty filter runs every target, as on a push or a full-ci PR). No # cmux-tui wait: the live-daemon suites skip unless the same-tree binary is @@ -1721,6 +1789,10 @@ jobs: needs: [path_route, macos-placement, push-head-preflight] if: ${{ !cancelled() && needs.path_route.outputs.native == 'true' && needs.push-head-preflight.outputs.current == 'true' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/feat-cmux-next' || startsWith(github.ref, 'refs/heads/next-batch/')) }} name: cmux-next Release compile (Xcode 26) + # Package-only Swift PRs have the bounded Swift canary as their merge gate. + # Keep this full Release compile visible and running for diagnostics, while + # retaining a hard failure on every feat-cmux-next push and on broader PRs. + continue-on-error: ${{ github.event_name == 'pull_request' && needs.path_route.outputs.swift_canary == 'true' }} # A push's Mac job waits for the same job of the running push run and is # never cancelled once it runs; a newer push replaces only the waiting one # (see the workflow concurrency comment). @@ -2131,7 +2203,7 @@ jobs: || echo "::warning::could not post the $CONTEXT status" push-attribution: - needs: [checks, generated-files, swift-test, daemon-test, release-compile, cmux-scheme-compile] + needs: [checks, generated-files, swift-canary, swift-test, daemon-test, release-compile, cmux-scheme-compile] # Also a same-tree mode run for a feat-cmux-next push: the tree jobs of a # push whose tree was published after its run probed it run there. if: ${{ failure() && (github.event_name == 'push' || inputs.same_tree_origin == 'push') && github.ref == 'refs/heads/feat-cmux-next' && github.repository == 'manaflow-ai/cmux' }} diff --git a/docs/ci/cmux-next-tiers.md b/docs/ci/cmux-next-tiers.md index 169348583055..e36d637331a7 100644 --- a/docs/ci/cmux-next-tiers.md +++ b/docs/ci/cmux-next-tiers.md @@ -9,6 +9,7 @@ job's summary lists each tier and the reason for it. | --- | --- | --- | --- | | checks | `cmux-next checks` (god files, concurrency, crash safety, string tables, script tests, package conventions, tier routing) | any cmux-next path | Linux (Blacksmith) | | generated | `cmux-next generated files` (action catalog, surfaces and inventory; CI target graph) | the CmuxNext package, `plans/cmux-next/`, the generators | mini | +| swift canary | `cmux-next Swift canary` (Debug SwiftPM build of the changed production targets) | production Swift files under `Packages/macOS/CmuxNext/Sources/` only | mini, placed first | | native | `cmux-next Release compile (Xcode 26)` | Swift or app sources | mini | | scheme | `cmux app scheme compile (Debug)` | the app host, Xcode project, CLI, resources, webviews, local packages CmuxNext uses, an executable target | mini | | swift | `cmux-next swift test` | the test targets the target graph reaches | mini | @@ -22,6 +23,15 @@ proves the committed bundle matches them; the bundle is a `.copy` resource, so the app takes it without a compile. gallery-pr diffs the touched entries. A `dev-build` PR still compiles its dogfood app. +The Swift canary is the pull-request gate for a small package-only Swift diff. +It builds only the affected CmuxNext production targets with `swift build +--configuration debug --target`, reusing the linked SwiftPM scratch on a mini. +It catches type-checking, dependency and module errors in those targets. It +does not exercise optimizer-only behavior, Release compiler settings, Xcode +scheme integration, signing or the CEF shim. The full Release compile still +runs for diagnostics on that PR class and remains hard-gated on every +`feat-cmux-next` push and on broader pull requests. + A CI-only change runs no Mac tier either: workflows, `scripts/ci/`, the router, `scripts/gh-merge-green` and their Python tests (alone or with web files). ci.yml's guards run actionlint and the CI unit tests. When the router itself changes, diff --git a/scripts/ci/cmux_next_route.py b/scripts/ci/cmux_next_route.py index 64e63951d965..70c1d45ae26a 100755 --- a/scripts/ci/cmux_next_route.py +++ b/scripts/ci/cmux_next_route.py @@ -10,6 +10,8 @@ generated action catalog, surfaces and inventory and the CI target graph are fresh (a mini; a stale file is fixed by a bot commit) native Swift or app sources: the Release compile + canary changed CmuxNext Swift targets type-check on a warm mini; this + is the pull-request gate for small package-only Swift diffs scheme app host, Xcode project, webviews or other local packages: the Debug compile of the cmux app scheme swift the CmuxNext test targets the change can affect, from the SwiftPM @@ -94,6 +96,12 @@ "scripts/cmux-next/build-app-ffi.sh", ) +# Keep the fast gate narrow. App-host, manifest, resource and toolchain edits +# continue to use the Release configuration as their pull-request gate. +CANARY_SWIFT_PREFIXES = ( + PACKAGE + "Sources/", +) + GENERATED_INPUTS = ( PACKAGE, "plans/cmux-next/", @@ -173,12 +181,14 @@ def consumed(path: str, tree_inputs: list[str], read) -> bool: class Route: full: bool = False native: bool = False + swift_canary: bool = False webview: bool = False scheme: bool = False generated: bool = False swift: bool = False daemon: bool = False tests: set[str] = field(default_factory=set) + swift_canary_targets: set[str] = field(default_factory=set) reasons: list[str] = field(default_factory=list) def everything(self, reason: str) -> None: @@ -331,6 +341,13 @@ def route(root: Path, event: str, changed: list[str] | None, labels: set[str]) - if result.tests: result.swift = True result.generated = True + if result.swift and changed_targets and all(canary_swift_path(path) for path in changed): + result.swift_canary_targets = canary_targets(graph, changed_targets) + result.swift_canary = bool(result.swift_canary_targets) + if result.swift_canary: + result.reasons.append( + "small CmuxNext Swift-only diff: the canary gates this pull request; Release remains advisory" + ) if result.webview: result.scheme = True if "dev-build" in labels and result.native: @@ -347,12 +364,34 @@ def swift_filter(result: Route) -> str: return "^(" + "|".join(sorted(result.tests)) + ")\\." if result.tests else "" +def canary_swift_path(path: str) -> bool: + return path.endswith(".swift") and any(path.startswith(prefix) for prefix in CANARY_SWIFT_PREFIXES) + + +def canary_targets(graph: dict, changed_targets: set[str]) -> set[str]: + """Return buildable production targets affected by changed package targets. + + Test sources remain covered by the normal swift-test tier. Mapping a test + target to its direct production targets keeps this lane a short module + type-check instead of rebuilding and linking every test bundle. + """ + targets: set[str] = set() + for name in changed_targets: + target = graph["targets"].get(name) + if not target: + continue + if target["kind"] in {"regular", "executable"}: + targets.add(name) + return targets + + def outputs(result: Route) -> dict[str, str]: flag = lambda value: "true" if value else "false" # noqa: E731 # Any tier that needs a Mac: the placement job places these runs. macos = result.native or result.webview or result.swift or result.generated or result.daemon return { "native": flag(result.native), + "swift_canary": flag(result.swift_canary), "macos": flag(macos), "scheme": flag(result.scheme or result.webview or result.full), "generated": flag(result.generated), @@ -361,6 +400,7 @@ def outputs(result: Route) -> dict[str, str]: "full": flag(result.full), "swift_filter": swift_filter(result), "swift_targets": "all" if result.full else " ".join(sorted(result.tests)), + "swift_canary_targets": " ".join(sorted(result.swift_canary_targets)), } @@ -388,9 +428,10 @@ def main(argv: list[str]) -> int: if args.summary: with args.summary.open("a", encoding="utf-8") as stream: stream.write("### cmux-next tiers\n\n| tier | runs |\n| --- | --- |\n") - for key in ("generated", "native", "scheme", "swift", "daemon", "full"): + for key in ("generated", "native", "swift_canary", "scheme", "swift", "daemon", "full"): stream.write(f"| {key} | {values[key]} |\n") stream.write(f"\nSwift test targets: {values['swift_targets'] or 'none'}\n\n") + stream.write(f"Swift canary targets: {values['swift_canary_targets'] or 'none'}\n\n") for reason in dict.fromkeys(result.reasons): stream.write(f"- {reason}\n") return 0 diff --git a/scripts/gh-merge-green b/scripts/gh-merge-green index b0e4f68c4c3e..51bb793ed29d 100755 --- a/scripts/gh-merge-green +++ b/scripts/gh-merge-green @@ -419,6 +419,7 @@ for name in open(sys.argv[2]).read().split(): esac done <<<"$next_verdicts" for next_check in \ + "cmux-next Swift canary" \ "cmux-next Release compile (Xcode 26)" \ "cmux app scheme compile (Debug)" \ "cmux-next swift test"; do From c980cc595b3ce9b2d244fb5598abda2cb3a048f4 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:28:35 -0700 Subject: [PATCH 09/15] cmux-next: repin app FFI to current base release (#18776) From 8da37f78e6cbd1c7aa57717e4cdfbf91cfd5c00c Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:28:39 -0700 Subject: [PATCH 10/15] cmux-next: same-tree cmux-tui is a hard check only for PRs that change cmux-tui (#18698) * test(cmux-next): red: a PR that changes no cmux-tui fails same-tree on an unpublished merge tree #16614, #17551 and #18605 change no cmux-tui or ghostty-next path, yet same-tree cmux-tui failed them because the base tip's tree was never published (its artifacts run was cancelled). Such a PR should reuse the newest published tree in its history (tree_fetch_key, fetched through CMUX_TUI_TREE_KEY), or skip the tree jobs when none is published, never fail. Co-Authored-By: Claude Opus 5.5 * ci(cmux-next): same-tree cmux-tui is a hard check only for PRs that change cmux-tui Path routing records whether the pull request's own diff touches cmux-tui/ or ghostty-next. For one that does not, a merge tree that is not ready no longer fails: the probe (CMUX_TUI_TREE_BASE_FALLBACK=1) resolves the newest published tree in the merge's history and reports ready with tree_fetch_key, which the daemon tests and the Debug scheme compile fetch through CMUX_TUI_TREE_KEY; with none published it reports skipped. Pushes, dispatches and PRs that change cmux-tui keep the hard check. Co-Authored-By: Claude Opus 5.5 * fix(cmux-next): the newest-published search deepens only this commit's history A bare `git fetch --deepen=50 origin` deepens every branch of origin: on this PR's first run it took path routing past its 5-minute limit (job 113609407061) before the probe's base fallback, which then resolved in under a second. Deepening only HEAD takes about 30 s on a depth-2 clone of feat-cmux-next. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/cmux-next.yml | 19 ++++++ scripts/cmux-next/pin-cmux-tui.sh | 68 +++++++++++++++---- .../tests/pin-cmux-tui-probe.test.sh | 38 +++++++++++ 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/.github/workflows/cmux-next.yml b/.github/workflows/cmux-next.yml index b2cfad0f236d..2eacd72ed0bf 100644 --- a/.github/workflows/cmux-next.yml +++ b/.github/workflows/cmux-next.yml @@ -418,6 +418,9 @@ jobs: # ready, deferred, superseded or failed; empty when no tree tier runs. tree_state: ${{ steps.marker-upload.outcome == 'failure' && 'failed' || steps.tree-final.outputs.tree_state || steps.tree.outputs.tree_state }} tree_key: ${{ steps.tree.outputs.tree_key }} + # A pull request that changes no cmux-tui path and whose own tree is not + # ready: the published tree its tree jobs fetch (CMUX_TUI_TREE_KEY). + tree_fetch_key: ${{ steps.tree-final.outputs.tree_fetch_key || steps.tree.outputs.tree_fetch_key }} tree_reason: ${{ steps.marker-upload.outcome == 'failure' && 'the tree wait marker was not uploaded, so no publish would start the tree jobs; re-run this run' || steps.tree-final.outputs.tree_reason || steps.tree.outputs.tree_reason }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -447,6 +450,14 @@ jobs: echo "changed files: $(wc -l < "$changed"); labels: ${labels:-none}" python3 scripts/ci/cmux_next_route.py --event "$EVENT_NAME" --changed-files "$changed" \ --labels "$labels" --github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY" + # Whether this pull request changes the cmux-tui it would test against. + # One that does not is never failed for an unpublished merge tree + # (the probe steps below, CMUX_TUI_TREE_BASE_FALLBACK). + if [[ "$EVENT_NAME" == pull_request ]] && ! grep -qE '^(cmux-tui/|ghostty-next$)' "$changed"; then + echo "own_tui=false" >> "$GITHUB_OUTPUT" + else + echo "own_tui=true" >> "$GITHUB_OUTPUT" + fi # One look (and one short re-check of a missing run list), no wait. - name: Probe the same-tree cmux-tui @@ -461,6 +472,7 @@ jobs: CMUX_TUI_TREE_PUBLISHER_SHA: ${{ github.event_name != 'pull_request' && github.sha || '' }} CMUX_TUI_TREE_DISPATCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && '1' || '' }} CMUX_TUI_TREE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CMUX_TUI_TREE_BASE_FALLBACK: ${{ steps.route.outputs.own_tui == 'false' && '1' || '' }} GH_TOKEN: ${{ github.token }} run: scripts/cmux-next/pin-cmux-tui.sh probe @@ -523,6 +535,7 @@ jobs: CMUX_TUI_TREE_PUBLISHER_SHA: ${{ github.event_name != 'pull_request' && github.sha || '' }} CMUX_TUI_TREE_DISPATCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && '1' || '' }} CMUX_TUI_TREE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CMUX_TUI_TREE_BASE_FALLBACK: ${{ steps.route.outputs.own_tui == 'false' && '1' || '' }} CMUX_TUI_TREE_RECHECK_SECONDS: "0" GH_TOKEN: ${{ github.token }} run: scripts/cmux-next/pin-cmux-tui.sh probe @@ -1412,6 +1425,9 @@ jobs: CMUX_NEXT_RUNNER: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" + # A pull request that changes no cmux-tui path runs on the newest + # published tree when its own is not ready (path routing's probe). + CMUX_TUI_TREE_KEY: ${{ inputs.same_tree_sha == '' && needs.path_route.outputs.tree_fetch_key || '' }} steps: - name: Clear stale git locks (self-hosted reused workspace) shell: bash @@ -1974,6 +1990,9 @@ jobs: CMUX_NEXT_RUNNER: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" + # A pull request that changes no cmux-tui path runs on the newest + # published tree when its own is not ready (path routing's probe). + CMUX_TUI_TREE_KEY: ${{ inputs.same_tree_sha == '' && needs.path_route.outputs.tree_fetch_key || '' }} steps: - name: Clear stale git locks (self-hosted reused workspace) shell: bash diff --git a/scripts/cmux-next/pin-cmux-tui.sh b/scripts/cmux-next/pin-cmux-tui.sh index e2f8ea546d31..5114ff9c2bd0 100755 --- a/scripts/cmux-next/pin-cmux-tui.sh +++ b/scripts/cmux-next/pin-cmux-tui.sh @@ -100,7 +100,8 @@ # With CMUX_TUI_TREE_DISPATCH=1 (and GH_TOKEN or an authenticated gh), a tree that # no active run publishes gets one cmux-tui-artifacts run on cmux-tui-pin-. # | probe (the cmux-next same-tree check: look once, never wait; writes tree_state= -# ready|deferred|superseded|failed, tree_key= and tree_reason= to GITHUB_OUTPUT) +# ready|deferred|superseded|failed|skipped, tree_key=, tree_fetch_key= and +# tree_reason= to GITHUB_OUTPUT) # | local-build (exit 0 when that build has this checkout's key) # | show | pin --commit [--verified-run ] set -euo pipefail @@ -167,6 +168,22 @@ tree_key() { legacy_tree_key() { python3 "$repo_root/scripts/ci/cmux_tui_tree_key.py" --version v1 "${1:-HEAD}" 2>/dev/null || true } +# The tree this checkout fetches and bundles: its own, or CMUX_TUI_TREE_KEY. +# cmux-next path routing sets that for a pull request that changes no +# cmux-tui path and whose own tree is not published (probe's tree_fetch_key): +# the tree jobs then run against the newest published tree in its history. +checkout_tree_key() { + if [[ -n "${CMUX_TUI_TREE_KEY:-}" ]]; then + [[ "$CMUX_TUI_TREE_KEY" =~ ^[0-9a-f]{40}$ ]] || { echo "error: CMUX_TUI_TREE_KEY must be a 40-hex tree key" >&2; exit 2; } + echo "$CMUX_TUI_TREE_KEY" + else + tree_key HEAD + fi +} +# The checkout's v1 key; none for a CMUX_TUI_TREE_KEY tree (always v2). +checkout_legacy_tree_key() { + [[ -n "${CMUX_TUI_TREE_KEY:-}" ]] || legacy_tree_key HEAD +} mode_from_args() { mode="${CMUX_NEXT_TUI_MODE:-tree}" @@ -689,7 +706,7 @@ fetch_tree_companions() { fetch_tree() { local key base_key wait_key dir binary url actual temp_dir file - key="$(tree_key HEAD)" + key="$(checkout_tree_key)" file="$(tree_binary_file)" if local_build_matches "${CMUX_TUI_CLIENT_LOCAL:-}"; then echo "same-tree cmux-tui $key: using the local build of this source, $CMUX_TUI_CLIENT_LOCAL" @@ -722,8 +739,11 @@ fetch_tree() { echo "pull-request cmux-tui tree $key differs from base tree $base_key; waiting for its own publication (bounded)" >&2 fi fi - require_target_in_tree "$wait_key" "$(legacy_tree_key HEAD)" - wait_for_tree "$wait_key" "$temp_dir/sha256" "$(legacy_tree_key HEAD)" + if [[ -n "${CMUX_TUI_TREE_KEY:-}" ]]; then + echo "cmux-tui tree $key from CMUX_TUI_TREE_KEY: this pull request changes no cmux-tui path, so it runs on the newest published tree in its history" >&2 + fi + require_target_in_tree "$wait_key" "$(checkout_legacy_tree_key)" + wait_for_tree "$wait_key" "$temp_dir/sha256" "$(checkout_legacy_tree_key)" if [[ "$published_key" != "$key" ]]; then echo "same-tree cmux-tui $key: using its v1 publication $published_key (CMUX-TUI-TREE-KEY-V2)" >&2 url="$BASE/tree/$published_key/$(tree_asset)" @@ -792,6 +812,11 @@ tree_published() { # tree_state=superseded a push whose artifacts runs were cancelled or skipped # while the branch moved on: nothing to test # tree_state=failed nothing will publish it (tree_reason says why) +# tree_state=skipped CMUX_TUI_TREE_BASE_FALLBACK=1 and no tree in the +# history is published: the tree jobs do not run +# With CMUX_TUI_TREE_BASE_FALLBACK=1 a tree that is not ready is replaced by +# the newest published one in the history (resolve-newest-published): ready, +# with tree_fetch_key= for the tree jobs' CMUX_TUI_TREE_KEY. # The publisher is CMUX_TUI_TREE_PUBLISHER_SHA's artifacts runs (push and # dispatch). A pull request whose merge keeps the base's tree waits for the base # push's runs. One with its own merge tree has no publisher (GitHub runs @@ -807,6 +832,7 @@ tree_published() { probe_checkout_tree() { local key legacy publisher="${CMUX_TUI_TREE_PUBLISHER_SHA:-}" local base_key="" source="" state="" reason="" recheck="${CMUX_TUI_TREE_RECHECK_SECONDS:-20}" superseded="" + local fetch_key="" resolved="" key="$(tree_key HEAD)" legacy="$(legacy_tree_key HEAD)" [[ "$legacy" == "$key" ]] && legacy="" @@ -867,6 +893,20 @@ probe_checkout_tree() { *) state=failed reason="could not read the cmux-tui artifacts runs for ${source#*:} (GitHub API), so no publisher of tree $key is known; re-run this check" ;; esac + # A pull request that changes no cmux-tui or ghostty-next path + # (CMUX_TUI_TREE_BASE_FALLBACK=1 from path routing) is not held to its merge + # tree: when that is not ready, its tree jobs run on the newest published + # tree in its history, or are skipped when there is none. Never failed. + if [[ "$state" != ready && "${CMUX_TUI_TREE_BASE_FALLBACK:-}" == 1 ]]; then + if resolved="$(CMUX_TUI_TREE_DISPATCH='' GITHUB_STEP_SUMMARY='' resolve_newest_published_tree)"; then + fetch_key="$(awk -F= '$1 == "key" { print $2 }' <<<"$resolved")" + reason="this pull request changes no cmux-tui or ghostty-next path; its tree $key is $state, so the tree jobs use the newest published tree $fetch_key (from $(awk -F= '$1 == "source_commit" { print substr($2, 1, 12) }' <<<"$resolved"))" + state=ready + else + reason="this pull request changes no cmux-tui or ghostty-next path, its tree $key is $state, and no tree in its recent history is published; the tree jobs are skipped" + state=skipped + fi + fi if [[ "$state" != ready ]]; then report_tree_miss "$key" case "${tree_publisher_action:-none}:$state" in @@ -879,6 +919,7 @@ probe_checkout_tree() { if [[ -n "${GITHUB_OUTPUT:-}" ]]; then { echo "tree_key=$key" + [[ -z "$fetch_key" ]] || echo "tree_fetch_key=$fetch_key" echo "tree_state=$state" echo "tree_reason=$(tr -d '\r\n' <<<"$reason")" } >> "$GITHUB_OUTPUT" @@ -943,9 +984,11 @@ resolve_newest_published_tree() { for same_path in ${same_paths[@]+"${same_paths[@]}"}; do tip_blobs+=("$(git rev-parse -q --verify "HEAD:$same_path" 2>/dev/null || echo missing)") done - # A depth-1 CI checkout holds no window; deepen once (best effort). + # A depth-1 CI checkout holds no window; deepen once (best effort). Only + # this commit's history: a bare `fetch --deepen` deepens every branch of + # origin, which outlasted path routing's 5 minutes (#18698). if [[ "$(git rev-parse --is-shallow-repository)" == true ]] && (( $(git rev-list --count HEAD) < limit )); then - git fetch -q --deepen="$limit" origin 2>/dev/null \ + git fetch -q --no-tags --deepen="$limit" origin "$tip" 2>/dev/null \ || echo "warning: could not deepen the shallow checkout; searching the history it has" >&2 fi temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/cmux-tui-tree.XXXXXX")" @@ -1155,7 +1198,7 @@ PY mode_from_args "$@" [[ "$mode" == tree ]] && TARGET="$(host_tree_target)" if [[ "$mode" == tree ]]; then - echo "$(tree_dir "$(tree_key HEAD)")/$(tree_binary_file)" + echo "$(tree_dir "$(checkout_tree_key)")/$(tree_binary_file)" else read_pin echo "$repo_root/cmux-tui/target/hosted/$pin_commit/cmux-tui" @@ -1165,7 +1208,7 @@ PY mode_from_args "$@" [[ "$mode" == tree ]] && TARGET="$(host_tree_target)" if [[ "$mode" == tree ]]; then - echo "$(tree_dir "$(tree_key HEAD)")/cmux-app-host" + echo "$(tree_dir "$(checkout_tree_key)")/cmux-app-host" else read_pin echo "$repo_root/cmux-tui/target/hosted/$pin_commit/cmux-app-host" @@ -1175,7 +1218,7 @@ PY mode_from_args "$@" [[ "$mode" == tree ]] && TARGET="$(host_tree_target)" if [[ "$mode" == tree ]]; then - echo "$(tree_dir "$(tree_key HEAD)")/cmux-cloud" + echo "$(tree_dir "$(checkout_tree_key)")/cmux-cloud" else read_pin echo "$repo_root/cmux-tui/target/hosted/$pin_commit/cmux-cloud" @@ -1185,7 +1228,7 @@ PY mode_from_args "$@" [[ "$mode" == tree ]] && TARGET="$(host_tree_target)" if [[ "$mode" == tree ]]; then - echo "$(tree_dir "$(tree_key HEAD)")/cmux-browser-host" + echo "$(tree_dir "$(checkout_tree_key)")/cmux-browser-host" else read_pin echo "$repo_root/cmux-tui/target/hosted/$pin_commit/cmux-browser-host" @@ -1207,7 +1250,7 @@ PY local_build_matches "${1:-}" ;; key) - rev=HEAD + rev="" version=v2 while [[ $# -gt 0 ]]; do case "$1" in @@ -1216,7 +1259,8 @@ PY *) usage >&2; exit 2 ;; esac done - python3 "$repo_root/scripts/ci/cmux_tui_tree_key.py" --version "$version" "$rev" + if [[ -z "$rev" && "$version" == v2 ]]; then checkout_tree_key; exit 0; fi + python3 "$repo_root/scripts/ci/cmux_tui_tree_key.py" --version "$version" "${rev:-HEAD}" ;; show) read_pin diff --git a/scripts/cmux-next/tests/pin-cmux-tui-probe.test.sh b/scripts/cmux-next/tests/pin-cmux-tui-probe.test.sh index 72f5677e0b86..2d3052daa004 100755 --- a/scripts/cmux-next/tests/pin-cmux-tui-probe.test.sh +++ b/scripts/cmux-next/tests/pin-cmux-tui-probe.test.sh @@ -198,4 +198,42 @@ grep -q "cmux-tui-pin-${head2_sha:0:12}.*$head2_sha" "$TMP/posts.log" || fail "( [[ "$(posts dispatches)" == 1 ]] || fail "(c) one dispatch: $(cat "$TMP/posts.log")" unset REFUSE_REF_SHA CMUX_TUI_TREE_HEAD_SHA CMUX_TUI_TREE_DISPATCH + +# (d) A pull request that changes no cmux-tui or ghostty-next path is not +# blocked by a merge tree nothing publishes (#16614, #17551, #18605): the base +# tip moved to a new tree whose artifacts run was cancelled. With +# CMUX_TUI_TREE_BASE_FALLBACK=1 (path routing sets it for such a PR) the probe +# reuses the newest published tree in the merge's history (tree_fetch_key=), +# and the tree jobs fetch it through CMUX_TUI_TREE_KEY. Without a published +# tree it skips the tree jobs instead of failing. A PR that changes cmux-tui +# keeps the hard check (cases a-c: the fallback is off). +published_key=$(cd "$TMP/src" && bash scripts/cmux-next/pin-cmux-tui.sh key --rev "$sha") +[[ -d "$TMP/cdn/cmux-tui/tree/$published_key" ]] || { echo "fixture: the app commit's tree is not published" >&2; exit 1; } +printf '{"commit":"%s"}\n' "$sha" > "$TMP/cdn/cmux-tui/tree/$published_key/source.json" +mkdir -p "$TMP/cdn/cmux-tui/$sha" +printf '{"binaries":{"cmux-tui-aarch64-apple-darwin":"%064d"}}\n' 0 > "$TMP/cdn/cmux-tui/$sha/manifest.json" +git_q -C "$TMP/src" checkout main +echo moved > "$TMP/src/cmux-tui/c" +git_q -C "$TMP/src" add -A +git_q -C "$TMP/src" commit -m base-moved +git_q -C "$TMP/src" checkout -b swift-only +echo swift > "$TMP/src/app2.swift" +git_q -C "$TMP/src" add -A +git_q -C "$TMP/src" commit -m swift-only +git_q -C "$TMP/src" checkout main +git_q -C "$TMP/src" merge --no-ff -m merge-swift-only swift-only +key=$(cd "$TMP/src" && bash scripts/cmux-next/pin-cmux-tui.sh key) +[[ "$key" != "$published_key" ]] || { echo "fixture: the moved base still has the published tree" >&2; exit 1; } +set_runs "$cancelled" +expect "pull request, no cmux-tui change, fallback off" pull_request failed "cancelled" +export CMUX_TUI_TREE_BASE_FALLBACK=1 +expect "pull request, no cmux-tui change, base tree reused" pull_request ready "changes no cmux-tui" +grep -qxF "tree_fetch_key=$published_key" "$TMP/gh-output" || fail "(d) the tree jobs must fetch the newest published tree $published_key: $(cat "$TMP/gh-output")" +fetched=$(cd "$TMP/src" && CMUX_TUI_TREE_KEY="$published_key" bash scripts/cmux-next/pin-cmux-tui.sh path) +[[ "$fetched" == *"/$published_key/"*cmux-tui ]] || fail "(d) CMUX_TUI_TREE_KEY must select the fetched tree: $fetched" +mv "$TMP/cdn/cmux-tui/tree/$published_key" "$TMP/unpublished" +expect "pull request, no cmux-tui change, nothing published" pull_request skipped "changes no cmux-tui" +mv "$TMP/unpublished" "$TMP/cdn/cmux-tui/tree/$published_key" +unset CMUX_TUI_TREE_BASE_FALLBACK + printf 'pin-cmux-tui probe tests: ok\n' From 1beff16b285c4b5de52640e8b164321eedbeb9c6 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:28:43 -0700 Subject: [PATCH 11/15] fix(ci): keep cmux-next minis first through bot retries (#18615) --- .github/workflows/cmux-next.yml | 24 +++++++++++++----------- tests/test_ci_side_lane_placement.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cmux-next.yml b/.github/workflows/cmux-next.yml index 2eacd72ed0bf..9353306b09a1 100644 --- a/.github/workflows/cmux-next.yml +++ b/.github/workflows/cmux-next.yml @@ -330,7 +330,9 @@ concurrency: # ci-owned-pool-rescue.yml's sweeper on main adopts the run # (owned_pool_rescue.SIDE_WORKFLOW_PATHS) and re-runs a job a mini leaves # queued or refuses. The second attempt stays on the side label; a later bot -# attempt is the measured overflow. +# attempt is the measured overflow. A re-run reuses macos-placement's runner +# output, so bot attempt 3 still goes to the minis; the rescue re-runs a +# refusal there as attempt 4, which drops that label and takes Blacksmith. jobs: # A push can sit in the owned-pool queue after newer commits have landed. # Resolve the branch head on Linux before any macOS job requests a runner. @@ -1184,7 +1186,7 @@ jobs: concurrency: group: ${{ github.event_name == 'push' && format('cmux-next-push-{0}-swift-test', github.ref) || format('cmux-next-run-{0}-swift-test', github.run_id) }} cancel-in-progress: false - runs-on: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + runs-on: ${{ (github.run_attempt <= 3 || github.triggering_actor != 'github-actions[bot]') && needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} permissions: contents: read # A cold build of the package graph (CmuxNext plus its Shared and iOS test @@ -1192,7 +1194,7 @@ jobs: timeout-minutes: 60 env: # This job's label (its runs-on): a glaeda-* label means a mini. - CMUX_NEXT_RUNNER: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + CMUX_NEXT_RUNNER: ${{ (github.run_attempt <= 3 || github.triggering_actor != 'github-actions[bot]') && needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} # The package deploys to macOS 26, so the test runner needs a macOS 26 # host and SDK. @@ -1413,7 +1415,7 @@ jobs: concurrency: group: ${{ github.event_name == 'push' && format('cmux-next-push-{0}-daemon-test', github.ref) || format('cmux-next-run-{0}-daemon-test', github.run_id) }} cancel-in-progress: false - runs-on: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + runs-on: ${{ (github.run_attempt <= 3 || github.triggering_actor != 'github-actions[bot]') && needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} # actions: read lets the same-tree cmux-tui fetch read the artifacts runs. permissions: actions: read @@ -1422,7 +1424,7 @@ jobs: statuses: write timeout-minutes: 60 env: - CMUX_NEXT_RUNNER: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + CMUX_NEXT_RUNNER: ${{ (github.run_attempt <= 3 || github.triggering_actor != 'github-actions[bot]') && needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" # A pull request that changes no cmux-tui path runs on the newest @@ -1625,14 +1627,14 @@ jobs: concurrency: group: ${{ github.event_name == 'push' && format('cmux-next-push-{0}-generated-files', github.ref) || format('cmux-next-run-{0}-generated-files', github.run_id) }} cancel-in-progress: false - runs-on: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + runs-on: ${{ (github.run_attempt <= 3 || github.triggering_actor != 'github-actions[bot]') && needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} permissions: contents: read timeout-minutes: 30 outputs: patch: ${{ steps.regenerate.outputs.patch }} env: - CMUX_NEXT_RUNNER: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + CMUX_NEXT_RUNNER: ${{ (github.run_attempt <= 3 || github.triggering_actor != 'github-actions[bot]') && needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" steps: @@ -1815,14 +1817,14 @@ jobs: concurrency: group: ${{ github.event_name == 'push' && format('cmux-next-push-{0}-release-compile', github.ref) || format('cmux-next-run-{0}-release-compile', github.run_id) }} cancel-in-progress: false - runs-on: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + runs-on: ${{ (github.run_attempt <= 3 || github.triggering_actor != 'github-actions[bot]') && needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} # One-architecture Release (-O, whole-module) xcodebuild of CmuxNextApp # with the nightly's Xcode. Debug `swift build` and Xcode 27 accept code # that this rejects; without this job such code fails only the nightly. timeout-minutes: 60 env: # This job's label (its runs-on): a glaeda-* label means a mini. - CMUX_NEXT_RUNNER: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + CMUX_NEXT_RUNNER: ${{ (github.run_attempt <= 3 || github.triggering_actor != 'github-actions[bot]') && needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" steps: @@ -1970,7 +1972,7 @@ jobs: concurrency: group: ${{ github.event_name == 'push' && format('cmux-next-push-{0}-cmux-scheme-compile', github.ref) || format('cmux-next-run-{0}-cmux-scheme-compile', github.run_id) }} cancel-in-progress: false - runs-on: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + runs-on: ${{ (github.run_attempt <= 3 || github.triggering_actor != 'github-actions[bot]') && needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} # actions: read lets the same-tree cmux-tui wait read the artifacts runs. permissions: actions: read @@ -1987,7 +1989,7 @@ jobs: timeout-minutes: 60 env: # This job's label (its runs-on): a glaeda-* label means a mini. - CMUX_NEXT_RUNNER: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} + CMUX_NEXT_RUNNER: ${{ (github.run_attempt <= 3 || github.triggering_actor != 'github-actions[bot]') && needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" # A pull request that changes no cmux-tui path runs on the newest diff --git a/tests/test_ci_side_lane_placement.py b/tests/test_ci_side_lane_placement.py index 3f0abccc9150..8cda9c20ce98 100644 --- a/tests/test_ci_side_lane_placement.py +++ b/tests/test_ci_side_lane_placement.py @@ -195,6 +195,21 @@ def test_trusted_attempts_use_the_live_route_until_overflow(self): triggering_actor="github-actions[bot]")), FALLBACK) self.assertEqual(evaluate(runs_on, self.context(fork=True)), FALLBACK) + def test_a_bot_attempt_4_leaves_the_placement_label_for_blacksmith(self): + # A re-run keeps attempt 1's placement output, so bot attempt 3 stays on + # the minis; the rescue re-runs a refusal there as attempt 4, which must + # leave the minis (#18147, 2026-10-07 02:23Z). + jobs = self.workflow()["jobs"] + bot = "github-actions[bot]" + for name in JOBS: + runs_on = jobs[name]["runs-on"] + with self.subTest(job=name): + self.assertEqual(evaluate(runs_on, self.context("3", runner=STD, triggering_actor=bot)), STD) + self.assertEqual(evaluate(runs_on, self.context("4", runner=STD, triggering_actor=bot)), FALLBACK) + self.assertEqual(evaluate(runs_on, self.context("5", runner=SIDE, triggering_actor=bot)), FALLBACK) + # A person's re-run stays minis-first at any attempt. + self.assertEqual(evaluate(runs_on, self.context("4", runner=STD)), STD) + def test_placement_starts_only_where_attempt_1_may_take_the_side_label(self): # A fork, another owner, owned pools off or a re-run starts no Linux runner before the Mac jobs. jobs = self.workflow()["jobs"] From 5c0a0fc7b18a577577956a8de3a00426d71a1d34 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:28:47 -0700 Subject: [PATCH 12/15] docs(cmux-next): map UI parity reference sources (#18561) * docs(cmux-next): map UI parity reference sources * docs(cmux-next): add media preview reference source * docs(cmux-next): define reference freshness checks --- plans/cmux-next/ui-reference-sources.md | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 plans/cmux-next/ui-reference-sources.md diff --git a/plans/cmux-next/ui-reference-sources.md b/plans/cmux-next/ui-reference-sources.md new file mode 100644 index 000000000000..9a89c100ed68 --- /dev/null +++ b/plans/cmux-next/ui-reference-sources.md @@ -0,0 +1,59 @@ +# cmux-next UI reference sources + +This is the source map for parity work on the cmux-next web UI. Keep the implementation and its +evidence in the source that owns it; use the other repositories as references or delivery tooling. + +## Source map + +| Source | Canonical contents | Use it for | Do not use it for | +| --- | --- | --- | --- | +| [`manaflow-ai/cmux`](https://github.com/manaflow-ai/cmux) | Production React components under `webviews/src`, gallery fixtures under `webviews/src/gallery`, gallery tests under `webviews/test`, and the matrix runner under `scripts/gallery-matrix` | UI behavior, accessibility, keyboard and pointer interaction, visual diffs, filmstrips, layout-shift and long-frame checks, settled-latency measurements | Treating a hosted screenshot as proof that a branch is currently deployed | +| [`manaflow-ai/messageslab`](https://github.com/manaflow-ai/messageslab) | Messages-style interaction prototypes, canonical conversation fixture, motion spring fits, and frame/benchmark tools | Conversation density, transcript hierarchy, selection and send motion, scroll and virtualization feel, reference interaction vocabulary | Copying a prototype implementation into cmux without checking the cmux state and host contracts | +| [`teamleaderleo/idlesse`](https://github.com/teamleaderleo/idlesse) | Native Library and Studio media browsing, still/video/scene preview, thumbnail and framing behavior, and media pipeline fixtures | Static-versus-motion preview treatment, gallery/contact-sheet affordances, media fit/fill choices, and long-lived preview feel | Treating a native media surface as a cmux webview implementation or as proof of cmux behavior | +| [`manaflow-ai/cmux-gallery`](https://github.com/manaflow-ai/cmux-gallery) | Preview router, branch build queue, static hosting, matrix index and deployment scripts | Serving a pushed branch, linking a repeatable preview, finding the build or matrix artifact for a commit | UI source: it explicitly consumes `webviews/src/gallery` from cmux | +| [`manaflow-ai/cmux-app-screenshots`](https://github.com/manaflow-ai/cmux-app-screenshots) | Native cmux captures, reference briefs and Computer Use evidence | Native app chrome, tab/sidebar spacing, titlebar and cross-surface visual references | Treating a native capture as a webview interaction test | + +## Evidence order for a UI change + +1. Start with the production component and its existing unit or interaction tests in `cmux`. +2. Add or update a gallery entry when the behavior is visual or gesture-driven. The fixture should + use the real component and reducer, not a second mock implementation. +3. Run the gallery matrix at merge-base, head and a repeat head render. Use filmstrips for motion, + and record settled latency for actions where responsiveness is part of the requirement. +4. Use `messageslab` to compare transcript and composer feel, and `cmux-app-screenshots` to compare + native chrome. These are reference evidence, not substitutes for the cmux gallery result. +5. Publish a branch preview through `cmux-gallery` only after the branch is pushed. Record the exact + commit and preview path with the review artifact. + +## Keeping references fresh + +- `cmux` is authoritative for shipped behavior. Review the exact branch head, the owning component, + and its focused test before comparing a reference surface. +- `messageslab` is consumed through the vendored `Packages/Shared/CmuxMessagesLab` copy. Check its + recorded source pin and patches with `scripts/cmux-next/check-messageslab-vendor.sh`; a local + prototype checkout is not a substitute for the pinned source. +- `cmux-app-screenshots` references are valid only when the capture directory, brief, and capture + commit are recorded together. Reuse the capture's stated window and scale when judging spacing. +- `idlesse` references should name the Library or Studio surface and the media fixture being compared; + the repository is a behavior and framing reference, not a cmux implementation dependency. +- `cmux-gallery` is delivery infrastructure. Confirm the published revision banner and matrix + artifact match the implementation commit before linking a preview in a review. + +## Current high-value parity surfaces + +- Composer controls and model picker: production behavior in `webviews/src/agent-session/acpmux`; + the keyboard specimen belongs in `composer.gallery.ts` and should cover focus, Escape, arrow + navigation, selection and settled latency. +- Transcript and selection: compare `messageslab/shared` fixtures and motion notes against the + real transcript entries, then prove selection, copy, hover and long-message behavior in gallery + fixtures. +- Overlays and menus: use the shared Base UI primitives in `webviews/src/ui`, with gallery play + steps for open, keyboard navigation, press-drag and Escape/focus return. +- Tabs and navigation: use native captures for spacing and close affordances, and cmux gallery + entries for keyboard order, focus return and narrow-pane behavior. + +## Review invariant + +Every parity claim should name the implementation commit, the gallery entry or test that exercises +it, and the reference source used for the comparison. A hosted preview or a screenshot without an +exact commit is a pointer, not evidence. From c08fa908d544e42921d5a55a623da346909f0c4b Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:28:51 -0700 Subject: [PATCH 13/15] fix(nightly): bound the publish notes step at 10 minutes (#18404) * test(nightly): the notes step cannot spend the publish budget before the upload (red) Run 37611677848 spent 29 of publish-nightly's 30 minutes on release notes and was cancelled mid-upload, leaving partial release assets. Co-Authored-By: Claude Opus 5.5 * fix(nightly): bound the publish notes step at 10 minutes The notes take seconds since 0115b2684cf. A regression now fails the step before any asset upload, instead of spending publish-nightly's 30 minutes and being cancelled mid-upload with partial assets left on the release. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 4 ++++ tests/test_nightly_release_notes.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 7ed89e72b814..96debc3d059c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -3240,6 +3240,10 @@ jobs: - name: Prepare nightly release notes and appcast summaries id: notes if: needs.decide.outputs.should_publish == 'true' + # The notes take seconds. Bounded here, a slow notes run fails before + # any asset upload instead of using up the job's 30 minutes and being + # cancelled mid-upload (run 37611677848 left partial assets that way). + timeout-minutes: 10 env: GH_TOKEN: ${{ github.token }} NOTES_HEAD: ${{ steps.source.outputs.build_sha }} diff --git a/tests/test_nightly_release_notes.py b/tests/test_nightly_release_notes.py index 54870c773ce1..ac895eee0c78 100755 --- a/tests/test_nightly_release_notes.py +++ b/tests/test_nightly_release_notes.py @@ -453,6 +453,23 @@ def test_nightly_next_notes_read_prs_merged_into_feat_cmux_next(self): "${{ needs.decide.outputs.track == 'nightly-next' && 'feat-cmux-next' || github.ref_name }}", ) + def test_notes_cannot_spend_the_publish_budget_before_the_upload(self): + # Run 37611677848 spent 29 of publish-nightly's 30 minutes on the notes, + # so the job was cancelled mid-upload and left partial release assets. + # The notes step has its own bound, well inside the job's, so a slow + # notes run fails before any asset is uploaded. + workflow = (ROOT / ".github/workflows/nightly.yml").read_text(encoding="utf-8") + job = workflow[workflow.index("\n publish-nightly:\n"):] + job = job[:re.search(r"\n [A-Za-z0-9_-]+:\n", job[1:]).start() + 1] + job_timeout = int(re.search(r"^ timeout-minutes: (\d+)$", job, re.M).group(1)) + step = job[job.index("- name: Prepare nightly release notes and appcast summaries"):] + step = step[:step.index("\n - name:", 1)] + bound = re.search(r"^ timeout-minutes: (\d+)$", step, re.M) + self.assertIsNotNone(bound, "the notes step needs its own timeout-minutes") + self.assertLessEqual(int(bound.group(1)) + 15, job_timeout, + "the job must keep 15 minutes for downloads and the asset upload after the notes") + self.assertLess(job.index("Prepare nightly release notes"), job.index("- name: Publish nightly release assets")) + if __name__ == "__main__": unittest.main(verbosity=2) From 0e4c30a4893553b67db6251c4960bf49e267946b Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:28:58 -0700 Subject: [PATCH 14/15] ci(cmux-next): retry bad R2 binary reads and let the tree gate pin itself (#17622) * test(cmux-next): the cmux-tui gate pins and dispatches an unpublished tree itself Publisher run 37505519359 failed transiently and its retry was replaced by a newer feat-cmux-next push, so the tree was never published and lanes pushed cmux-tui-pin-* branches by hand to unblock their gates. Co-Authored-By: Claude Opus 5.5 * test(cmux-next): the tree gate watches the base and may pin the tree Co-Authored-By: Claude Opus 5.5 * ci(cmux-next): the cmux-tui gate pins and dispatches an unpublished tree When two run checks find no artifacts run that will publish the tree, the gate creates cmux-tui-pin- at the publisher commit and dispatches the artifacts workflow there, once, then keeps waiting. A pull request now watches its base commit (the merge's first parent) instead of no publisher, so it neither waits 45 minutes for a tree nobody builds nor needs a lane to push a pin branch by hand. A superseded push still skips; forks keep a read-only token and no autopin. Co-Authored-By: Claude Opus 5.5 * test(cmux-tui): a transient wrong binary read must not fail publication Co-Authored-By: Claude Opus 5.5 * fix(cmux-tui): read a mismatched published binary again before failing R2 objects are write-once, so a digest mismatch on a binary the run just uploaded is a bad read. Fetch it again from a cache-busting URL up to three times, and name the response headers when it still differs. Co-Authored-By: Claude Opus 5.5 * test(cmux-next): a pull request gate pins a tree its base never builds Co-Authored-By: Claude Opus 5.5 * ci(cmux-next): a pull request gate pins a tree its base never builds A pull request that changes cmux-tui has a merge tree no push builds, and the artifacts workflow's pull_request_target trigger has never started a run, so its gate waited 45 minutes with the run check off. With CMUX_TUI_TREE_AUTOPIN the gate now pins its own checkout at once and watches that run. Co-Authored-By: Claude Opus 5.5 * ci(cmux-next): retain base tree dispatch tests --------- Co-authored-by: Claude Opus 5.5 --- .../scripts/test_verify_published_manifest.py | 46 ++++++++++++++++ cmux-tui/scripts/verify_published_manifest.py | 53 ++++++++++++++++--- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/cmux-tui/scripts/test_verify_published_manifest.py b/cmux-tui/scripts/test_verify_published_manifest.py index 18f024ca0034..d5f1a6f18c84 100644 --- a/cmux-tui/scripts/test_verify_published_manifest.py +++ b/cmux-tui/scripts/test_verify_published_manifest.py @@ -127,6 +127,52 @@ def verify(self, manifest: dict[str, object]) -> None: required_artifacts=(self.WINDOWS,), ) + def binary_reads(self, manifest: dict[str, object], *payloads: bytes) -> tuple[list[str], object]: + """urlopen that serves the manifest, then each binary read in turn; records the URLs.""" + seen: list[str] = [] + reads = iter(payloads) + + def opener(request: object, timeout: float = 0) -> object: + url = request.full_url # type: ignore[attr-defined] + seen.append(url) + if url.endswith("/manifest.json"): + return FakeResponse(manifest) + response = FakeResponse({}) + response.body = next(reads) + response.headers = {"Content-Length": str(len(response.body)), "ETag": '"e1"', "CF-Cache-Status": "HIT"} + return response + + return seen, opener + + def test_a_transient_wrong_binary_read_is_retried_with_a_fresh_url(self) -> None: + """Run 37505519359 read other bytes for a binary R2 held correctly, and the publish failed.""" + good = b"browser-host" + manifest = {"commit": self.COMMIT, "binaries": {"cmux-tui-browser-host-x86_64-apple-darwin": hashlib.sha256(good).hexdigest()}} + seen, opener = self.binary_reads(manifest, b"stale bytes", good) + with patch.object(VERIFY, "urlopen", side_effect=opener), patch.object(VERIFY, "RETRY_PAUSE_SECONDS", 0, create=True): + VERIFY.verify_manifest( + "https://files.example/cmux-tui/sha/manifest.json", + expected_commit=self.COMMIT, + required_artifacts=(), + artifact_base_url="https://files.example/cmux-tui/sha", + ) + binary_urls = [url for url in seen if not url.endswith("/manifest.json")] + self.assertEqual(len(binary_urls), 2) + self.assertNotEqual(binary_urls[0], binary_urls[1]) + self.assertTrue(binary_urls[1].startswith(binary_urls[0] + "?")) + + def test_a_persistent_wrong_binary_names_what_the_server_sent(self) -> None: + manifest = {"commit": self.COMMIT, "binaries": {"cmux-tui-browser-host-x86_64-apple-darwin": "a" * 64}} + seen, opener = self.binary_reads(manifest, b"x", b"x", b"x") + with patch.object(VERIFY, "urlopen", side_effect=opener), patch.object(VERIFY, "RETRY_PAUSE_SECONDS", 0, create=True): + with self.assertRaisesRegex(VERIFY.ManifestError, r"digest mismatch .* after 3 reads .*size 1.*CF-Cache-Status HIT"): + VERIFY.verify_manifest( + "https://files.example/cmux-tui/sha/manifest.json", + expected_commit=self.COMMIT, + required_artifacts=(), + artifact_base_url="https://files.example/cmux-tui/sha", + ) + def test_accepts_required_windows_artifact_and_digest(self) -> None: self.verify(self.manifest()) diff --git a/cmux-tui/scripts/verify_published_manifest.py b/cmux-tui/scripts/verify_published_manifest.py index 22519076aa57..9fb237256114 100644 --- a/cmux-tui/scripts/verify_published_manifest.py +++ b/cmux-tui/scripts/verify_published_manifest.py @@ -14,6 +14,7 @@ import json import re import sys +import time import urllib.error import urllib.request from pathlib import Path @@ -42,6 +43,10 @@ # Raw binaries are intentionally bounded. This prevents a malformed public # manifest from turning the post-publish verifier into an unbounded download. MAX_ARTIFACT_BYTES = 512 << 20 +# A wrong binary read is fetched again this many times, from a fresh URL, before it fails. +BINARY_READ_ATTEMPTS = 3 +RETRY_PAUSE_SECONDS = 5 +DIAGNOSTIC_HEADERS = ("Content-Length", "ETag", "Last-Modified", "CF-Cache-Status", "CF-Ray", "Age") class ManifestError(ValueError): @@ -329,7 +334,9 @@ def _validate_manifest( raise ManifestError(f"{source} has invalid cmux-tui SHA-256 for {artifact}") -def _read_url(url: str, *, max_bytes: int = MAX_MANIFEST_BYTES) -> bytes: +def _read_url( + url: str, *, max_bytes: int = MAX_MANIFEST_BYTES, headers_out: dict[str, str] | None = None +) -> bytes: request = urllib.request.Request( url, headers={ @@ -340,7 +347,14 @@ def _read_url(url: str, *, max_bytes: int = MAX_MANIFEST_BYTES) -> bytes: ) try: with urlopen(request, timeout=30) as response: - content_length = getattr(response, "headers", {}).get("Content-Length") + response_headers = getattr(response, "headers", {}) + if headers_out is not None: + headers_out.clear() + for name in DIAGNOSTIC_HEADERS: + value = response_headers.get(name) + if value is not None: + headers_out[name] = str(value) + content_length = response_headers.get("Content-Length") if content_length is not None: try: if int(content_length) > max_bytes: @@ -366,6 +380,31 @@ def _read_url(url: str, *, max_bytes: int = MAX_MANIFEST_BYTES) -> bytes: raise ManifestError(f"could not fetch {url}: {error}") from error +def _verify_remote_binary(url: str, artifact: str, expected: str, *, source: str) -> None: + """Compare a published binary with its digest, reading it again from a fresh URL on a mismatch. + + Write-once objects never change, so a wrong read is a transient fetch problem, not a + different build. Run 37505519359 failed on one such read while R2 held the right bytes. + """ + + reads: list[str] = [] + for attempt in range(BINARY_READ_ATTEMPTS): + headers: dict[str, str] = {} + fetch_url = url if attempt == 0 else f"{url}?verify={time.time_ns()}" + payload = _read_url(fetch_url, max_bytes=MAX_ARTIFACT_BYTES, headers_out=headers) + actual = hashlib.sha256(payload).hexdigest() + if actual.lower() == expected.lower(): + return + details = ", ".join(f"{name} {value}" for name, value in headers.items()) + reads.append(f"read {attempt + 1}: got {actual}, size {len(payload)}" + (f", {details}" if details else "")) + if attempt + 1 < BINARY_READ_ATTEMPTS: + print(f"warning: {source} digest mismatch for {artifact} on {reads[-1]}; reading again", file=sys.stderr) + time.sleep(RETRY_PAUSE_SECONDS) + raise ManifestError( + f"{source} digest mismatch for {artifact}: expected {expected} after {BINARY_READ_ATTEMPTS} reads ({'; '.join(reads)})" + ) + + def _read_file(path: Path, *, max_bytes: int = MAX_ARTIFACT_BYTES) -> bytes: try: size = path.stat().st_size @@ -401,10 +440,12 @@ def _verify_binary_digests( for artifact, expected in binaries.items(): if artifact_base_url is not None: base = artifact_base_url.rstrip("/") - payload = _read_url(f"{base}/{quote(artifact, safe='')}", max_bytes=MAX_ARTIFACT_BYTES) - else: - assert artifact_directory is not None - payload = _read_file(artifact_directory / artifact) + _verify_remote_binary( + f"{base}/{quote(artifact, safe='')}", artifact, str(expected), source=source + ) + continue + assert artifact_directory is not None + payload = _read_file(artifact_directory / artifact) actual = hashlib.sha256(payload).hexdigest() if actual.lower() != str(expected).lower(): raise ManifestError( From d808aa11ed5f22038f0f34c81cf3161015ff331e Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 9 Oct 2026 19:29:01 -0700 Subject: [PATCH 15/15] ci(gallery): capture narrow responsive states (#18901) * ci(gallery): capture narrow responsive states * test(gallery): cover workflow matrix changes * fix(gallery): dedupe equal responsive widths * ci: shard gallery matrix renders * ci: retrigger sharded gallery validation --- .github/workflows/gallery-pr.yml | 43 +++++++++++++++++++++-------- scripts/gallery-matrix/README.md | 2 +- webviews/scripts/gallery/touched.ts | 1 + 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.github/workflows/gallery-pr.yml b/.github/workflows/gallery-pr.yml index 176dd6d42917..921e5ded0b69 100644 --- a/.github/workflows/gallery-pr.yml +++ b/.github/workflows/gallery-pr.yml @@ -29,23 +29,26 @@ concurrency: cancel-in-progress: true env: - # The matrix: both default themes (dark and light), both engines, the pane at its normal width. + # The matrix: both default themes (dark and light), both engines, and both compact and normal + # pane widths so responsive regressions are visible in the same before/after receipt. GALLERY_THEMES: pair GALLERY_ENGINES: chromium,webkit + GALLERY_WIDTHS: narrow,normal jobs: render: if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} - # Keep all three evidence stages, but give each its own runner and timeout. A broad gallery - # change can select hundreds of states; sequential stages otherwise consume the whole hour. + # Keep all three evidence stages, but shard each stage. A broad gallery change can select + # thousands of browser states; sequential rendering otherwise consumes the whole hour. # Isolated jobs also avoid competing for renderer memory or CPU while measuring play steps. - timeout-minutes: 60 + timeout-minutes: 30 strategy: fail-fast: false - max-parallel: 3 + max-parallel: 6 matrix: stage: [base, head, repeat] + shard: [0, 1, 2, 3] outputs: touched: ${{ steps.touched.outputs.count }} base_sha: ${{ steps.base.outputs.sha }} @@ -107,7 +110,13 @@ jobs: IDS: ${{ steps.touched.outputs.ids }} run: | set -euo pipefail - bun scripts/gallery/manifest.ts --entries "$IDS" --themes "$GALLERY_THEMES" --frame component --out "$RUNNER_TEMP/head.json" + manifest="$RUNNER_TEMP/head.json" + bun scripts/gallery/manifest.ts --entries "$IDS" --themes "$GALLERY_THEMES" --widths "$GALLERY_WIDTHS" --frame component --out "$manifest" + # A few entries intentionally map narrow and normal to the same pixels. Keep one + # rendered state for those entries so the matrix remains valid without losing a + # visually distinct width anywhere else. + jq 'unique_by(.id)' "$manifest" > "$manifest.tmp" + mv "$manifest.tmp" "$manifest" CMUX_GALLERY_OUT_DIR="$RUNNER_TEMP/gallery-head" bun run gallery:build # The merge-base renders with its own gallery code: a change to the gallery shows as a change. @@ -127,7 +136,10 @@ jobs: exit 0 fi bun install --frozen-lockfile - if ! { bun scripts/gallery/manifest.ts --entries "$IDS" --themes "$GALLERY_THEMES" --frame component --out "$RUNNER_TEMP/base.json" && + manifest="$RUNNER_TEMP/base.json" + if ! { bun scripts/gallery/manifest.ts --entries "$IDS" --themes "$GALLERY_THEMES" --widths "$GALLERY_WIDTHS" --frame component --out "$manifest" && + jq 'unique_by(.id)' "$manifest" > "$manifest.tmp" && + mv "$manifest.tmp" "$manifest" && CMUX_GALLERY_OUT_DIR="$RUNNER_TEMP/gallery-base" bun run gallery:build; }; then echo "[]" > "$RUNNER_TEMP/base.json" rm -rf "$RUNNER_TEMP/gallery-base" @@ -150,20 +162,20 @@ jobs: echo "$(jq length "$RUNNER_TEMP/cases.json") cases x $GALLERY_ENGINES" gallery="$RUNNER_TEMP/gallery-head" if [ "$STAGE" = base ]; then gallery="$RUNNER_TEMP/gallery-base"; fi - out="$RUNNER_TEMP/evidence/runs/$STAGE" + out="$RUNNER_TEMP/evidence/runs/$STAGE/shard-${{ matrix.shard }}" mkdir -p "$out" if [ -d "$gallery" ]; then # A stage that fails to mount makes the runner exit 1; compare.ts reports it. A # missing results.json is an infrastructure failure, never a silently empty stage. bun runner.ts --manifest "$RUNNER_TEMP/cases.json" --gallery-dir "$gallery" --output-dir "$out" \ - --engines "$GALLERY_ENGINES" || true + --engines "$GALLERY_ENGINES" --shard-count 4 --shard-index "${{ matrix.shard }}" || true test -f "$out/results.json" else test "$STAGE" = base echo '[]' > "$out/results.json" fi # Only one job supplies common inputs so artifact merging never overwrites a sibling. - if [ "$STAGE" = head ]; then + if [ "$STAGE" = head ] && [ "${{ matrix.shard }}" = 0 ]; then cp "$RUNNER_TEMP/head.json" "$RUNNER_TEMP/base.json" "$RUNNER_TEMP/touched.json" "$RUNNER_TEMP/evidence/" cp -r "$RUNNER_TEMP/gallery-head" "$RUNNER_TEMP/evidence/gallery" fi @@ -172,7 +184,7 @@ jobs: if: ${{ steps.touched.outputs.count != '0' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: gallery-stage-${{ matrix.stage }} + name: gallery-stage-${{ matrix.stage }}-${{ matrix.shard }} path: ${{ runner.temp }}/evidence retention-days: 14 @@ -201,6 +213,15 @@ jobs: merge-multiple: true path: ${{ runner.temp }}/evidence + - name: Merge sharded evidence + run: | + set -euo pipefail + evidence="$RUNNER_TEMP/evidence" + for stage in base head repeat; do + mkdir -p "$evidence/runs/$stage" + jq -s 'add' "$evidence"/runs/$stage/shard-*/results.json > "$evidence/runs/$stage/results.json" + done + - name: Compare working-directory: scripts/gallery-matrix env: diff --git a/scripts/gallery-matrix/README.md b/scripts/gallery-matrix/README.md index febc1cbae13f..02fdfcde1035 100644 --- a/scripts/gallery-matrix/README.md +++ b/scripts/gallery-matrix/README.md @@ -35,7 +35,7 @@ The local runner reuses one browser per engine but recycles it when a renderer t ## Per-PR diff -`.github/workflows/gallery-pr.yml` runs on each PR to feat-cmux-next that touches the webviews. `webviews/scripts/gallery/touched.ts` picks the entries the change reaches: an entry's own file, anything its `covers` import, and every entry of a host whose stylesheet changed. A gallery or build-config change picks every entry. The job renders those entries x variants x both default themes x both engines on Linux at the merge-base and at the head, and at the head a second time. +`.github/workflows/gallery-pr.yml` runs on each PR to feat-cmux-next that touches the webviews. `webviews/scripts/gallery/touched.ts` picks the entries the change reaches: an entry's own file, anything its `covers` import, and every entry of a host whose stylesheet changed. A gallery or build-config change picks every entry. The job renders those entries x variants x both default themes x both compact and normal pane widths x both engines on Linux at the merge-base and at the head, and at the head a second time. `pr.ts` (with `compare.ts` and `report.ts`) compares each state and writes `diff/`: diff --git a/webviews/scripts/gallery/touched.ts b/webviews/scripts/gallery/touched.ts index f45a095bd9ea..8a578751211b 100644 --- a/webviews/scripts/gallery/touched.ts +++ b/webviews/scripts/gallery/touched.ts @@ -25,6 +25,7 @@ const EVERYTHING = [ /^webviews\/package\.json$/, /^bun\.lock$/, /^scripts\/gallery-matrix\//, + /^\.github\/workflows\/gallery-pr\.yml$/, ]; const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".css"];