From 1dfa2c218f7e6487efe5baf1ca06246dd45163d7 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 31 May 2026 19:19:44 +0800 Subject: [PATCH 1/3] refactor(gui): extract macOS status item wrapper --- crates/openlogi-gui/src/platform/mod.rs | 2 + .../openlogi-gui/src/platform/status_item.rs | 296 ++++++++++++++++++ crates/openlogi-gui/src/platform/tray.rs | 199 ++++-------- 3 files changed, 367 insertions(+), 130 deletions(-) create mode 100644 crates/openlogi-gui/src/platform/status_item.rs diff --git a/crates/openlogi-gui/src/platform/mod.rs b/crates/openlogi-gui/src/platform/mod.rs index 0e2ebeb..1fe146f 100644 --- a/crates/openlogi-gui/src/platform/mod.rs +++ b/crates/openlogi-gui/src/platform/mod.rs @@ -2,5 +2,7 @@ pub mod launch_agent; pub mod single_instance; +#[cfg(target_os = "macos")] +mod status_item; pub mod tray; pub mod updater; diff --git a/crates/openlogi-gui/src/platform/status_item.rs b/crates/openlogi-gui/src/platform/status_item.rs new file mode 100644 index 0000000..effd910 --- /dev/null +++ b/crates/openlogi-gui/src/platform/status_item.rs @@ -0,0 +1,296 @@ +//! Thin macOS `NSStatusItem` / `NSMenu` wrapper used by the OpenLogi tray. + +#![expect( + unsafe_code, + reason = "Cocoa NSStatusItem/NSMenu FFI; GPUI has no menu-bar API" +)] + +use std::sync::Once; + +use cocoa::base::{NO, YES, id, nil}; +use cocoa::foundation::NSString; +use objc::declare::ClassDecl; +use objc::runtime::{Class, Object, Sel}; +use objc::{class, msg_send, sel, sel_impl}; + +/// Objective-C action callback signature used by status-item menu entries. +pub(super) type ActionCallback = extern "C" fn(&Object, Sel, id); + +/// macOS application activation policy values used by OpenLogi. +#[derive(Clone, Copy)] +pub(super) enum ActivationPolicy { + /// Standard Dock + app menu-bar presence. + Regular, + /// Hide from Dock/app menu bar while keeping the status item alive. + Accessory, +} + +impl ActivationPolicy { + fn as_raw(self) -> i64 { + match self { + Self::Regular => 0, + Self::Accessory => 1, + } + } +} + +/// Set the process-wide AppKit activation policy. +pub(super) fn set_activation_policy(policy: ActivationPolicy) { + let app: id = unsafe { + // SAFETY: `NSApplication.sharedApplication` is a process-wide AppKit + // singleton and is available on the main thread after GPUI starts. + msg_send![class!(NSApplication), sharedApplication] + }; + unsafe { + // SAFETY: `app` is the shared `NSApplication`, and the integer value is + // one of AppKit's documented activation policies. + let _: () = msg_send![app, setActivationPolicy: policy.as_raw()]; + } +} + +/// A retained `NSStatusItem` handle. +#[derive(Clone, Copy)] +pub(super) struct StatusItem(usize); + +impl StatusItem { + /// Create and retain a variable-width status item. + pub(super) fn new() -> Self { + const VARIABLE_LENGTH: f64 = -1.0; + + let status_bar: id = unsafe { + // SAFETY: `NSStatusBar.systemStatusBar` returns the process-wide + // status bar when called from the main AppKit thread. + msg_send![class!(NSStatusBar), systemStatusBar] + }; + let status_item: id = unsafe { + // SAFETY: `status_bar` is AppKit's shared `NSStatusBar`; variable + // length is the documented `NSVariableStatusItemLength` sentinel. + msg_send![status_bar, statusItemWithLength: VARIABLE_LENGTH] + }; + unsafe { + // SAFETY: the newly-created status item is a valid Objective-C + // object; retaining keeps it alive for the app lifetime. + let _: id = msg_send![status_item, retain]; + } + Self(status_item as usize) + } + + /// Show or hide the status item without tearing it down. + pub(super) fn set_visible(&self, visible: bool) { + let flag = if visible { YES } else { NO }; + unsafe { + // SAFETY: `self.raw()` is the retained `NSStatusItem` created by + // `StatusItem::new`; `setVisible:` accepts an Objective-C BOOL. + let _: () = msg_send![self.raw(), setVisible: flag]; + } + } + + /// Attach a menu to the status item. + pub(super) fn set_menu(&self, menu: Menu) { + unsafe { + // SAFETY: both handles are retained AppKit objects created by this + // module, and `setMenu:` does not take Rust ownership. + let _: () = msg_send![self.raw(), setMenu: menu.raw()]; + } + } + + /// Use an SF Symbol as the status-item icon, falling back to a text title. + pub(super) fn set_symbol_icon(&self, symbol: &str, description: &str, fallback_title: &str) { + let button: id = unsafe { + // SAFETY: `self.raw()` is a valid retained `NSStatusItem`; AppKit + // returns its button or nil on unsupported versions. + msg_send![self.raw(), button] + }; + let image: id = unsafe { + // SAFETY: the selector is an AppKit constructor; the arguments are + // temporary `NSString`s valid for the duration of the message send. + msg_send![class!(NSImage), imageWithSystemSymbolName: nsstring(symbol) accessibilityDescription: nsstring(description)] + }; + if image == nil { + unsafe { + // SAFETY: `button` is the status-item button returned by + // AppKit; setting a text title is valid when no image exists. + let _: () = msg_send![button, setTitle: nsstring(fallback_title)]; + } + } else { + unsafe { + // SAFETY: `image` is a valid `NSImage`; `setTemplate:` accepts + // an Objective-C BOOL and does not transfer ownership. + let _: () = msg_send![image, setTemplate: YES]; + } + unsafe { + // SAFETY: `button` and `image` are AppKit objects; `setImage:` + // stores the image according to AppKit ownership rules. + let _: () = msg_send![button, setImage: image]; + } + } + } + + fn raw(&self) -> id { + self.0 as id + } +} + +/// A retained `NSMenu` handle. +#[derive(Clone, Copy)] +pub(super) struct Menu(usize); + +impl Menu { + /// Create and retain a menu with AppKit auto-enabling disabled. + pub(super) fn new() -> Self { + let menu: id = unsafe { + // SAFETY: `NSMenu.new` creates a valid AppKit menu on the main + // thread. + msg_send![class!(NSMenu), new] + }; + unsafe { + // SAFETY: `menu` is a valid Objective-C object; retaining keeps it + // alive for the status item's lifetime. + let _: id = msg_send![menu, retain]; + } + unsafe { + // SAFETY: `menu` is a valid `NSMenu`; disabling auto-enabling is a + // standard AppKit property mutation. + let _: () = msg_send![menu, setAutoenablesItems: NO]; + } + Self(menu as usize) + } + + /// Append a menu item. + pub(super) fn add_item(&self, item: MenuItem) { + unsafe { + // SAFETY: `self` and `item` are valid AppKit objects created by + // this module; `addItem:` retains according to AppKit rules. + let _: () = msg_send![self.raw(), addItem: item.raw()]; + } + } + + /// Append a separator item. + pub(super) fn add_separator(&self) { + let separator: id = unsafe { + // SAFETY: `NSMenuItem.separatorItem` returns a valid autoreleased + // separator item suitable for adding to a menu. + msg_send![class!(NSMenuItem), separatorItem] + }; + unsafe { + // SAFETY: `self.raw()` is a valid `NSMenu`, and `separator` is a + // valid `NSMenuItem` returned by AppKit. + let _: () = msg_send![self.raw(), addItem: separator]; + } + } + + fn raw(&self) -> id { + self.0 as id + } +} + +/// A retained `NSMenuItem` handle. +#[derive(Clone, Copy)] +pub(super) struct MenuItem(usize); + +impl MenuItem { + /// Create a disabled title-only item. + pub(super) fn disabled(title: &str) -> Self { + let item: id = unsafe { + // SAFETY: `NSMenuItem.new` creates a valid menu item on the main + // AppKit thread. + msg_send![class!(NSMenuItem), new] + }; + unsafe { + // SAFETY: `item` is a valid `NSMenuItem`; the temporary `NSString` + // is valid for the duration of the message send. + let _: () = msg_send![item, setTitle: nsstring(title)]; + } + unsafe { + // SAFETY: `item` is a valid `NSMenuItem`; `setEnabled:` accepts an + // Objective-C BOOL. + let _: () = msg_send![item, setEnabled: NO]; + } + Self(item as usize) + } + + /// Create an action item targeting the supplied Objective-C receiver. + pub(super) fn action(title: &str, action: Sel, target: &ActionTarget) -> Self { + let item: id = unsafe { + // SAFETY: `NSMenuItem.alloc` allocates a menu item object for the + // following initializer. + msg_send![class!(NSMenuItem), alloc] + }; + let item: id = unsafe { + // SAFETY: `item` is allocated, `action` is registered on `target`, + // and the `NSString` arguments live for this message send. + msg_send![item, initWithTitle: nsstring(title) action: action keyEquivalent: nsstring("")] + }; + unsafe { + // SAFETY: `item` is an initialized `NSMenuItem`, and `target.raw()` + // is a retained Objective-C target object. + let _: () = msg_send![item, setTarget: target.raw()]; + } + Self(item as usize) + } + + /// Replace the menu item title. + pub(super) fn set_title(&self, title: &str) { + unsafe { + // SAFETY: `self.raw()` is a valid `NSMenuItem`; the temporary + // `NSString` is valid for the duration of the message send. + let _: () = msg_send![self.raw(), setTitle: nsstring(title)]; + } + } + + fn raw(&self) -> id { + self.0 as id + } +} + +/// A retained Objective-C target object for menu actions. +pub(super) struct ActionTarget(usize); + +impl ActionTarget { + /// Register the target class once and create a retained target instance. + pub(super) fn new(class_name: &'static str, methods: &[(Sel, ActionCallback)]) -> Self { + register_target_class(class_name, methods); + let target_cls = Class::get(class_name).unwrap_or_else(|| class!(NSObject)); + let target: id = unsafe { + // SAFETY: `target_cls` is either the registered target class or + // NSObject fallback; `new` returns a valid Objective-C object. + msg_send![target_cls, new] + }; + // NSMenuItem keeps only a weak reference to its target — retain it so it + // outlives the caller and the action callbacks stay valid. + unsafe { + // SAFETY: `target` is a valid Objective-C object created above; + // retaining intentionally leaks it for the app lifetime. + let _: id = msg_send![target, retain]; + } + Self(target as usize) + } + + fn raw(&self) -> id { + self.0 as id + } +} + +fn register_target_class(class_name: &'static str, methods: &[(Sel, ActionCallback)]) { + static REGISTER: Once = Once::new(); + REGISTER.call_once(|| { + if let Some(mut decl) = ClassDecl::new(class_name, class!(NSObject)) { + for (selector, callback) in methods { + unsafe { + // SAFETY: each callback uses the Objective-C method ABI and + // matches the selector signature used by `NSMenuItem`. + decl.add_method(*selector, *callback); + } + } + decl.register(); + } + }); +} + +fn nsstring(s: &str) -> id { + unsafe { + // SAFETY: `NSString::alloc(nil).init_str` constructs an Objective-C + // string from a Rust `&str`; callers use it immediately in msg_send. + NSString::alloc(nil).init_str(s) + } +} diff --git a/crates/openlogi-gui/src/platform/tray.rs b/crates/openlogi-gui/src/platform/tray.rs index f39af8f..bc51ba4 100644 --- a/crates/openlogi-gui/src/platform/tray.rs +++ b/crates/openlogi-gui/src/platform/tray.rs @@ -17,21 +17,19 @@ pub use macos::{ }; #[cfg(target_os = "macos")] -#[expect( - unsafe_code, - reason = "Cocoa NSStatusItem/NSMenu FFI; GPUI has no menu-bar API" -)] mod macos { - use std::sync::{Once, OnceLock}; + use std::sync::OnceLock; - use cocoa::base::{NO, YES, id, nil}; - use cocoa::foundation::NSString; - use objc::declare::ClassDecl; - use objc::runtime::{Class, Object, Sel}; - use objc::{class, msg_send, sel, sel_impl}; + use cocoa::base::id; + use objc::runtime::{Object, Sel}; + use objc::{sel, sel_impl}; use tokio::sync::mpsc; use tracing::warn; + use super::super::status_item::{ + self, ActionCallback, ActionTarget, ActivationPolicy, Menu, MenuItem, StatusItem, + }; + /// A request raised by clicking a status-bar menu item, or by a live /// language switch asking the drain task to re-localize the whole menu. #[derive(Debug, Clone, Copy)] @@ -42,29 +40,31 @@ mod macos { Refresh, } - const VARIABLE_LENGTH: f64 = -1.0; - const ACTIVATION_POLICY_REGULAR: i64 = 0; - const ACTIVATION_POLICY_ACCESSORY: i64 = 1; const TARGET_CLASS: &str = "OpenLogiMenuTarget"; // Read by the Objective-C action callbacks, which can't capture state. static MENU_TX: OnceLock> = OnceLock::new(); /// Open/Quit item pointers, kept so a live locale switch can re-title them. - /// Stored as `usize` because a raw `id` is not `Sync`. + /// Stored as opaque menu-item handles; only touched on the main thread. static MENU_REFS: OnceLock = OnceLock::new(); - /// The device-status line item, written by [`set_device_status`]. Stored as - /// `usize` (a raw `id` is not `Sync`); only ever touched on the main thread. - static DEVICE_ITEM: OnceLock = OnceLock::new(); + /// The device-status line item, written by [`set_device_status`]. Only ever + /// touched on the main thread. + static DEVICE_ITEM: OnceLock = OnceLock::new(); - /// The `NSStatusItem` itself, so [`set_visible`] can show / hide the icon - /// without tearing it down. `usize` (a raw `id` isn't `Sync`); main thread. - static STATUS_ITEM: OnceLock = OnceLock::new(); + /// The `NSStatusItem` itself, so [`set_visible`] can show / hide the icon. + static STATUS_ITEM: OnceLock = OnceLock::new(); struct MenuRefs { - open: usize, - quit: usize, + open: MenuItem, + quit: MenuItem, + } + + struct InstalledMenu { + menu: Menu, + refs: MenuRefs, + device_item: MenuItem, } /// Install the status item. Main thread only. @@ -77,63 +77,66 @@ mod macos { /// weak reference to it. pub fn install(tx: mpsc::UnboundedSender) { let _ = MENU_TX.set(tx); - ensure_target_class(); - - unsafe { - let status_bar: id = msg_send![class!(NSStatusBar), systemStatusBar]; - let status_item: id = msg_send![status_bar, statusItemWithLength: VARIABLE_LENGTH]; - let _: id = msg_send![status_item, retain]; - let _ = STATUS_ITEM.set(status_item as usize); - let button: id = msg_send![status_item, button]; - set_button_icon(button); + let status_item = StatusItem::new(); + let _ = STATUS_ITEM.set(status_item); + status_item.set_symbol_icon("computermouse.fill", "OpenLogi", "OpenLogi"); - let target_cls = Class::get(TARGET_CLASS).unwrap_or_else(|| class!(NSObject)); - let target: id = msg_send![target_cls, new]; - // NSMenuItem keeps only a weak reference to its target — retain it so - // it outlives this function and the action callbacks stay valid. - let _: id = msg_send![target, retain]; - - let menu: id = msg_send![class!(NSMenu), new]; - let _: id = msg_send![menu, retain]; - let _: () = msg_send![menu, setAutoenablesItems: NO]; - - let device_item: id = msg_send![class!(NSMenuItem), new]; - let idle = rust_i18n::t!("No device connected"); - let _: () = msg_send![device_item, setTitle: nsstring(&idle)]; - let _: () = msg_send![device_item, setEnabled: NO]; - let _: () = msg_send![menu, addItem: device_item]; - let _ = DEVICE_ITEM.set(device_item as usize); + let installed_menu = build_menu(); + let _ = DEVICE_ITEM.set(installed_menu.device_item); + let _ = MENU_REFS.set(installed_menu.refs); + status_item.set_menu(installed_menu.menu); + } - let separator: id = msg_send![class!(NSMenuItem), separatorItem]; - let _: () = msg_send![menu, addItem: separator]; + fn build_menu() -> InstalledMenu { + let target = action_target(); + let menu = Menu::new(); - let open_title = rust_i18n::t!("Open OpenLogi"); - let open_item = action_item(&open_title, sel!(openOpenLogi:), target); - let _: () = msg_send![menu, addItem: open_item]; - let quit_title = rust_i18n::t!("Quit OpenLogi"); - let quit_item = action_item(&quit_title, sel!(quitOpenLogi:), target); - let _: () = msg_send![menu, addItem: quit_item]; + let idle = rust_i18n::t!("No device connected"); + let device_item = MenuItem::disabled(&idle); + menu.add_item(device_item); - let _ = MENU_REFS.set(MenuRefs { - open: open_item as usize, - quit: quit_item as usize, - }); + menu.add_separator(); - let _: () = msg_send![status_item, setMenu: menu]; + let open_selector = sel!(openOpenLogi:); + let quit_selector = sel!(quitOpenLogi:); + let open_title = rust_i18n::t!("Open OpenLogi"); + let open_item = MenuItem::action(&open_title, open_selector, &target); + menu.add_item(open_item); + let quit_title = rust_i18n::t!("Quit OpenLogi"); + let quit_item = MenuItem::action(&quit_title, quit_selector, &target); + menu.add_item(quit_item); + + InstalledMenu { + menu, + refs: MenuRefs { + open: open_item, + quit: quit_item, + }, + device_item, } } + fn action_target() -> ActionTarget { + let open_selector = sel!(openOpenLogi:); + let quit_selector = sel!(quitOpenLogi:); + let target_methods = [ + (open_selector, open_action as ActionCallback), + (quit_selector, quit_action as ActionCallback), + ]; + ActionTarget::new(TARGET_CLASS, &target_methods) + } + /// Show the app in the Dock + menu bar — called when a window opens, so the /// app menu (⌘Q, Settings, …) is available while the window is up. pub fn show_in_dock() { - set_activation_policy(ACTIVATION_POLICY_REGULAR); + status_item::set_activation_policy(ActivationPolicy::Regular); } /// Drop the app out of the Dock + menu bar, leaving only the status item — /// called when the last window closes (and on a `--minimized` launch). pub fn hide_from_dock() { - set_activation_policy(ACTIVATION_POLICY_ACCESSORY); + status_item::set_activation_policy(ActivationPolicy::Accessory); } /// Show or hide the status-item icon without tearing it down — backs the @@ -142,17 +145,7 @@ mod macos { let Some(item) = STATUS_ITEM.get() else { return; }; - let flag = if visible { YES } else { NO }; - unsafe { - let _: () = msg_send![*item as id, setVisible: flag]; - } - } - - fn set_activation_policy(policy: i64) { - unsafe { - let app: id = msg_send![class!(NSApplication), sharedApplication]; - let _: () = msg_send![app, setActivationPolicy: policy]; - } + item.set_visible(visible); } /// Update the device line, e.g. `"MX Master 3S · 80%"`. Main thread only. @@ -161,10 +154,7 @@ mod macos { let Some(item) = DEVICE_ITEM.get() else { return; }; - unsafe { - let title = nsstring(text); - let _: () = msg_send![*item as id, setTitle: title]; - } + item.set_title(text); } /// Re-title the Open/Quit items for the current locale. Main-thread only, @@ -176,12 +166,8 @@ mod macos { }; let open_title = rust_i18n::t!("Open OpenLogi"); let quit_title = rust_i18n::t!("Quit OpenLogi"); - unsafe { - let open = refs.open as id; - let quit = refs.quit as id; - let _: () = msg_send![open, setTitle: nsstring(&open_title)]; - let _: () = msg_send![quit, setTitle: nsstring(&quit_title)]; - } + refs.open.set_title(&open_title); + refs.quit.set_title(&quit_title); } /// Ask the drain task to re-localize the whole menu after a live language @@ -192,34 +178,6 @@ mod macos { post(TrayEvent::Refresh); } - fn nsstring(s: &str) -> id { - unsafe { NSString::alloc(nil).init_str(s) } - } - - fn action_item(title: &str, action: Sel, target: id) -> id { - unsafe { - let item: id = msg_send![class!(NSMenuItem), alloc]; - let item: id = msg_send![item, initWithTitle: nsstring(title) action: action keyEquivalent: nsstring("")]; - let _: () = msg_send![item, setTarget: target]; - item - } - } - - // Template image adapts to the light/dark menu bar; text title as fallback. - fn set_button_icon(button: id) { - unsafe { - let symbol = nsstring("computermouse.fill"); - let description = nsstring("OpenLogi"); - let image: id = msg_send![class!(NSImage), imageWithSystemSymbolName: symbol accessibilityDescription: description]; - if image == nil { - let _: () = msg_send![button, setTitle: nsstring("OpenLogi")]; - } else { - let _: () = msg_send![image, setTemplate: YES]; - let _: () = msg_send![button, setImage: image]; - } - } - } - extern "C" fn open_action(_this: &Object, _cmd: Sel, _sender: id) { post(TrayEvent::Open); } @@ -235,23 +193,4 @@ mod macos { warn!(?event, "menu-bar event dropped — GPUI loop gone"); } } - - fn ensure_target_class() { - static REGISTER: Once = Once::new(); - REGISTER.call_once(|| { - if let Some(mut decl) = ClassDecl::new(TARGET_CLASS, class!(NSObject)) { - unsafe { - decl.add_method( - sel!(openOpenLogi:), - open_action as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(quitOpenLogi:), - quit_action as extern "C" fn(&Object, Sel, id), - ); - } - decl.register(); - } - }); - } } From 52c68ef819e48c6d2220bb5bd0400bd3402f9e74 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 31 May 2026 19:28:32 +0800 Subject: [PATCH 2/3] ci(release-plz): fail loudly when a release silently stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-plz/action swallows a release-pr HTTP 422 as a warning and reports no PR created, so releases can stall silently — indistinguishable from a quiet week of commits (this is how releases quietly stopped before the v0.1.3 manual release). Add a guard step that runs only when no PR was created/updated: if no release PR is already open yet release-worthy commits (feat/fix/perf/breaking) exist since the last tag, fail the job so the stall is visible. The prune step already removes the stale-branch 422 trigger; this catches any other swallowed failure mode. --- .github/workflows/release-plz.yml | 36 +++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index c8590f8..12608f2 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -22,7 +22,7 @@ jobs: group: release-plz-pr-${{ github.ref }} cancel-in-progress: false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false @@ -62,7 +62,7 @@ jobs: - name: Mint GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ steps.load_secrets.outputs.GITHUB_APP_ID }} private-key: ${{ steps.app_key.outputs.pem }} @@ -87,12 +87,40 @@ jobs: fi done - name: Run release-plz (release-pr) + id: release_pr uses: release-plz/action@v0.5 with: command: release-pr env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} CARGO_REGISTRY_TOKEN: ${{ steps.load_secrets.outputs.CARGO_REGISTRY_TOKEN }} + # `release-plz/action` swallows a release-pr HTTP 422 as a warning and + # reports no PR, which silently stalls releases (it looks identical to a + # quiet week of commits). Fail loudly when release-plz opened/updated no + # release PR, none is already open, yet release-worthy commits exist since + # the last tag — that combination means a swallowed failure, not a no-op. + - name: Guard against a silently stalled release + if: steps.release_pr.outputs.prs_created == 'false' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + run: | + # An already-open release PR is the normal "updated, not created" path. + open_release_pr=$(gh pr list --repo "$REPO" --state open --json headRefName \ + --jq '[.[] | select(.headRefName | startswith("release-plz-"))] | length') + if [ "$open_release_pr" != "0" ]; then + echo "A release PR is already open — nothing to flag." + exit 0 + fi + last_tag=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true) + range="${last_tag:+$last_tag..}HEAD" + worthy=$(git log "$range" --format='%s' \ + | grep -cE '^(feat|fix|perf)(\(.+\))?!?:|^[a-z]+(\(.+\))?!:' || true) + if [ "$worthy" != "0" ]; then + echo "::error::release-plz opened no release PR, but $worthy release-worthy commit(s) exist since ${last_tag:-repo start} and none is open — likely a swallowed release-plz failure (see the release-pr step)." + exit 1 + fi + echo "No release PR and no release-worthy commits since ${last_tag:-repo start}; nothing to release." # On every push to master, publishes any crate whose manifest version is not yet # on crates.io — i.e. a no-op until the release PR is merged, at which point it @@ -104,7 +132,7 @@ jobs: contents: write pull-requests: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false @@ -141,7 +169,7 @@ jobs: - name: Mint GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ steps.load_secrets.outputs.GITHUB_APP_ID }} private-key: ${{ steps.app_key.outputs.pem }} From ce1e70b63f9e3392d9d139001bdc4d9b894857c2 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 31 May 2026 19:42:11 +0800 Subject: [PATCH 3/3] chore: update workflow actions for Node 24 --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/release.yml | 37 +++++++++++++++++++---------------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9c605a..a3b4862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: name: rustfmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt @@ -25,7 +25,7 @@ jobs: name: clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -43,7 +43,7 @@ jobs: name: cargo check (linux) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: | @@ -64,7 +64,7 @@ jobs: name: tests (linux) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: | @@ -80,7 +80,7 @@ jobs: name: tests (macos) runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo test --workspace --all-targets @@ -89,7 +89,7 @@ jobs: name: cargo check (windows) runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 # openlogi-gui's GPUI/Metal deps don't build on Windows yet — restrict to diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e49ce32..90d5c26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: env: OPENLOGI_BUNDLE_ASSETS: ${{ vars.OPENLOGI_BUNDLE_ASSETS }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable @@ -121,20 +121,23 @@ jobs: codesign --verify --verbose=2 "$dmg" - name: Notarize DMG - uses: lando/notarize-action@v2 - with: - product-path: target/release/OpenLogi.dmg - appstore-connect-username: ${{ steps.load_secrets.outputs.APPLE_ID }} - appstore-connect-password: ${{ steps.load_secrets.outputs.APPLE_PASSWORD }} - appstore-connect-team-id: ${{ steps.load_secrets.outputs.APPLE_TEAM_ID }} - primary-bundle-id: org.openlogi.openlogi - tool: notarytool - verbose: true + env: + APPLE_ID: ${{ steps.load_secrets.outputs.APPLE_ID }} + APPLE_PASSWORD: ${{ steps.load_secrets.outputs.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ steps.load_secrets.outputs.APPLE_TEAM_ID }} + run: | + set -euo pipefail + xcrun notarytool submit target/release/OpenLogi.dmg \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait - name: Staple DMG - uses: BoundfoxStudios/action-xcode-staple@v1 - with: - product-path: target/release/OpenLogi.dmg + run: | + set -euo pipefail + xcrun stapler staple target/release/OpenLogi.dmg + xcrun stapler validate target/release/OpenLogi.dmg - name: Collect DMG artifact run: | @@ -144,7 +147,7 @@ jobs: cp target/release/OpenLogi.dmg "dist/OpenLogi-${ref_name}-macos.dmg" shasum -a 256 dist/*.dmg > dist/SHA256SUMS - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: OpenLogi-macos-dmg path: | @@ -152,7 +155,7 @@ jobs: dist/SHA256SUMS - name: Attach DMG to GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: | dist/*.dmg @@ -175,7 +178,7 @@ jobs: - name: Mint homebrew-tap token (GitHub App) id: tap_token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ steps.load_secrets.outputs.GITHUB_APP_ID }} private-key: ${{ steps.app_key.outputs.pem }} @@ -183,7 +186,7 @@ jobs: repositories: homebrew-tap - name: Dispatch homebrew-tap update - uses: peter-evans/repository-dispatch@v3 + uses: peter-evans/repository-dispatch@v4 with: token: ${{ steps.tap_token.outputs.token }} repository: AprilNEA/homebrew-tap