From cd5148808d8125ac860fbf9bf14105e23e6e0365 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:56:02 +0200 Subject: [PATCH 1/5] feat(configurator): explicit reset protocol and consuming clear confirm Replace the Defaults flip (second Requested press applied) with the unconditional three-message protocol: Requested arms only, Confirmed applies while armed, Canceled disarms while armed so a stray cancel cannot blank an unrelated status. The header shows explicit Defaults / Confirm Defaults / Cancel controls instead of a relabeled button. Session Clear confirmation now consumes pending_clear_id before setting busy, so a response-closed dialog can never look like a live A->A slot; completion's clear stays as a harmless redundancy. Both routes carry the new protocol, consumption, and armed-row-collapse tests. --- configurator/README.md | 2 +- configurator/src/app/component.rs | 63 ++++++-- configurator/src/app/pages/session.rs | 32 ++++ configurator/src/app/update/config.rs | 145 ++++++++++++++++-- configurator/src/app/update/mod.rs | 2 + .../src/app/update/session_catalog.rs | 5 + .../src/app/update/session_catalog/tests.rs | 87 +++++++++++ configurator/src/messages.rs | 8 + 8 files changed, 320 insertions(+), 24 deletions(-) diff --git a/configurator/README.md b/configurator/README.md index 94826286..3e12afee 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -57,7 +57,7 @@ if its UI task is no longer observed. - **Reload** – re-read `config.toml` from disk and refresh the guarded source revision. A transient load error leaves the last good document and current draft in place. - **Configuration update available** – shown when the file's `config_revision` predates this build's keybinding defaults. The banner lists every proposed shortcut change as before → after; **Apply Update** edits the draft only, and **Dismiss** hides the offer for this run. Nothing reaches disk until you Save, and saving an unrelated setting without applying leaves both the old bindings and the old revision on disk. -- **Defaults** – drop in the built-in defaults without saving. +- **Defaults** – drop in the built-in defaults without saving. Pressing it asks first: **Confirm Defaults** replaces the draft and **Cancel** withdraws the question, and editing anything withdraws it too. Pressing **Defaults** again changes nothing. - **Save** – validate inputs (including numeric ranges and color arrays), merge known changes into the source TOML, and write it atomically. An existing file is backed up with a timestamp. Save is refused if the file was created, deleted, retargeted through a symlink, or changed byte-for-byte after loading; reload before retrying. If a readable file cannot be parsed, the configurator offers a warning-marked defaults-based repair draft and backs up the unreadable source before saving it. Unknown settings are retained only when the TOML structure is parseable and safely separable; malformed content remains in the backup. - **Search** – filter tabs, sections, saved sessions, boards, render profiles, presets, and keybindings as you type. Press `Ctrl+F` to focus search and `Escape` to clear it. - Launch from the main overlay with the default `F11` keybinding (configurable inside the app). diff --git a/configurator/src/app/component.rs b/configurator/src/app/component.rs index ca2eb382..5e053930 100644 --- a/configurator/src/app/component.rs +++ b/configurator/src/app/component.rs @@ -55,6 +55,8 @@ pub(crate) struct AppWidgets { migration_seen: String, save_button: gtk::Button, defaults_button: gtk::Button, + defaults_confirm_button: gtk::Button, + defaults_cancel_button: gtk::Button, reload_button: gtk::Button, sidebar_rows: Vec<(TabId, gtk::ListBoxRow)>, sidebar: gtk::ListBox, @@ -165,6 +167,11 @@ impl Component for ConfiguratorApp { let sender = sender.clone(); reload_button.connect_clicked(move |_| sender.input(Message::ReloadRequested)); } + // Asking for the reset and answering for it are different messages, + // so they are different controls: while the confirmation stands, the + // button that asks steps aside for the pair that answers. All three + // exist from the start and visibility picks between them, which is + // what keeps a repeat of the same press from ever applying defaults. let defaults_button = gtk::Button::with_label("Defaults"); { let sender = sender.clone(); @@ -172,6 +179,33 @@ impl Component for ConfiguratorApp { sender.input(Message::ResetToDefaultsRequested); }); } + let defaults_confirm_button = gtk::Button::builder() + .label("Confirm Defaults") + .visible(false) + .css_classes(["destructive-action"]) + .build(); + { + let sender = sender.clone(); + defaults_confirm_button.connect_clicked(move |_| { + sender.input(Message::ResetToDefaultsConfirmed); + }); + } + let defaults_cancel_button = gtk::Button::builder() + .label("Cancel") + .visible(false) + .css_classes(["flat"]) + .build(); + { + let sender = sender.clone(); + defaults_cancel_button.connect_clicked(move |_| { + sender.input(Message::ResetToDefaultsCanceled); + }); + } + let defaults_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); + defaults_box.append(&defaults_button); + defaults_box.append(&defaults_confirm_button); + defaults_box.append(&defaults_cancel_button); + let save_button = gtk::Button::with_label("Save"); save_button.add_css_class("suggested-action"); { @@ -183,7 +217,7 @@ impl Component for ConfiguratorApp { .title_widget(&window_title) .build(); header.pack_start(&reload_button); - header.pack_start(&defaults_button); + header.pack_start(&defaults_box); header.pack_end(&save_button); // ---- Status + migration strip ----------------------------------- @@ -320,6 +354,8 @@ impl Component for ConfiguratorApp { migration_seen: String::new(), save_button, defaults_button, + defaults_confirm_button, + defaults_cancel_button, reload_button, sidebar_rows, sidebar, @@ -369,14 +405,13 @@ impl Component for ConfiguratorApp { if widgets.reload_button.is_sensitive() == busy { widgets.reload_button.set_sensitive(!busy); } - let defaults_label = if self.defaults_reset_pending { - "Confirm reset?" - } else { - "Defaults" - }; - if widgets.defaults_button.label().as_deref() != Some(defaults_label) { - widgets.defaults_button.set_label(defaults_label); - } + // The armed confirmation is the model's, so which of the two Defaults + // affordances is on screen follows it: asking is offered until the + // question stands, answering only while it does. + let defaults_armed = self.defaults_reset_pending; + set_visible(&widgets.defaults_button, !defaults_armed); + set_visible(&widgets.defaults_confirm_button, defaults_armed); + set_visible(&widgets.defaults_cancel_button, defaults_armed); // Status strip. let (status_text, status_class) = match &self.status { @@ -448,6 +483,16 @@ impl Component for ConfiguratorApp { } } +/// Writes the widget's own visibility flag, never `is_visible`: a widget +/// inside a hidden parent reports invisible while its own flag still says +/// otherwise, and skipping the write there would leak the stale state the +/// moment the parent comes back. +fn set_visible(widget: &impl IsA, visible: bool) { + if widget.get_visible() != visible { + widget.set_visible(visible); + } +} + /// Runs one effect as a Relm4 command; its result re-enters the component /// as an ordinary message through `update_cmd`. fn spawn_effect(effect: Effect, sender: &ComponentSender) { diff --git a/configurator/src/app/pages/session.rs b/configurator/src/app/pages/session.rs index d5c12a6c..54b0effa 100644 --- a/configurator/src/app/pages/session.rs +++ b/configurator/src/app/pages/session.rs @@ -831,6 +831,38 @@ mod tests { assert!(!clear_armed(None, "one")); } + /// Confirming consumes the pending id as it sets the catalog busy, so the + /// card leaves its armed state in the same refresh that starts the work: + /// the Confirm/Cancel pair goes away and the button that asks comes back + /// unpressable, rather than offering a confirm the model would refuse. + #[test] + fn a_confirmed_clear_collapses_the_armed_row_into_the_busy_one() { + let mut app = app_with_items(vec![test_item("one", "First")]); + app.session_catalog.pending_clear_id = Some("one".to_string()); + let summary = app.search_summary(); + let armed = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + assert!(armed.clear_armed); + + // What `handle_session_catalog_clear_confirmed` leaves behind: the + // answered question consumed, the clear running. + app.session_catalog.pending_clear_id = None; + app.session_catalog.busy = true; + let running = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + + assert!(!running.clear_armed); + assert!(!running.clear_enabled); + } + #[test] fn whole_number_validation_matches_the_old_hints() { assert_eq!(validate_whole_number("1000", 1000, u64::MAX), None); diff --git a/configurator/src/app/update/config.rs b/configurator/src/app/update/config.rs index 36285a46..96efdeb3 100644 --- a/configurator/src/app/update/config.rs +++ b/configurator/src/app/update/config.rs @@ -64,20 +64,35 @@ impl ConfiguratorApp { Vec::new() } - /// One button drives the two-step reset: the first press arms the - /// confirm (the shell relabels the button "Confirm reset?"), the second - /// press while armed applies the defaults. Any other edit disarms it - /// through `refresh_dirty_flag`, which is the old Cancel path. + /// Arms the confirmation, and only that. + /// + /// Asking is one message and answering is another, so no amount of + /// pressing the control that asks can replace the draft: a repeat while + /// the confirmation already stands is nothing, which is what makes a + /// double-click on "Defaults" harmless. Any other edit disarms it through + /// `refresh_dirty_flag`, which is the same standing-down the Cancel + /// control asks for explicitly. pub(super) fn handle_reset_to_defaults_requested(&mut self) -> Vec { - if self.is_loading || self.is_saving { + if self.is_loading || self.is_saving || self.defaults_reset_pending { return Vec::new(); } + self.defaults_reset_pending = true; + self.status = StatusMessage::warning( + "Defaults will replace the current draft with built-in defaults. Press \"Confirm Defaults\" to continue.", + ); + Vec::new() + } + + /// Applies the defaults, and only while the confirmation this answers is + /// still armed. + /// + /// The pending flag is the whole guard: every transition that could have + /// invalidated the question — a load, a save, a reload, an edit to the + /// draft the user is about to lose — clears it, so a confirmation that + /// outlived its question answers nothing. + pub(super) fn handle_reset_to_defaults_confirmed(&mut self) -> Vec { if !self.defaults_reset_pending { - self.defaults_reset_pending = true; - self.status = StatusMessage::warning( - "Defaults will replace the current draft with built-in defaults. Press \"Confirm reset?\" to continue.", - ); return Vec::new(); } @@ -92,6 +107,21 @@ impl ConfiguratorApp { Vec::new() } + /// Stands the confirmation down and takes its hint off the status line. + /// + /// Guarded on the same flag as the confirm: with nothing armed there is + /// no question to withdraw, so a stray cancel must not wipe a status the + /// user is reading. + pub(super) fn handle_reset_to_defaults_canceled(&mut self) -> Vec { + if !self.defaults_reset_pending { + return Vec::new(); + } + + self.defaults_reset_pending = false; + self.status = StatusMessage::idle(); + Vec::new() + } + pub(super) fn handle_save_requested(&mut self) -> Vec { // `is_loading` counts too: a reload replaces the draft and base // document when it lands, so a save started underneath it would write @@ -1000,21 +1030,39 @@ mod tests { assert!(app.defaults_reset_pending); assert_eq!(app.draft, changed_draft); - assert!(status_contains(&app.status, "Confirm reset?")); + assert!(status_contains(&app.status, "Confirm Defaults")); } - /// The same button applies the reset once the confirm is armed — the - /// wiring gap where "Confirm reset?" could never fire. + /// Asking twice is still asking: the request message cannot apply the + /// defaults, so a double press on "Defaults" leaves the draft alone with + /// the question still standing. #[test] - fn reset_to_defaults_second_press_applies_the_defaults() { + fn reset_to_defaults_repeated_request_is_a_no_op() { let (mut app, _effects) = ConfiguratorApp::new_app(); app.is_loading = false; app.draft.capture_enabled = !app.defaults.capture_enabled; - app.baseline.capture_enabled = !app.defaults.capture_enabled; + let changed_draft = app.draft.clone(); let _ = app.handle_reset_to_defaults_requested(); let _ = app.handle_reset_to_defaults_requested(); + assert!(app.defaults_reset_pending); + assert_eq!(app.draft, changed_draft); + assert!(status_contains(&app.status, "Confirm Defaults")); + } + + /// The confirm is the only message that replaces the draft, and it + /// disarms the question it answered. + #[test] + fn reset_to_defaults_confirmed_applies_the_defaults() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft.capture_enabled = !app.defaults.capture_enabled; + app.baseline.capture_enabled = !app.defaults.capture_enabled; + + let _ = app.handle_reset_to_defaults_requested(); + let _ = app.handle_reset_to_defaults_confirmed(); + assert_eq!(app.draft, app.defaults); assert!(!app.defaults_reset_pending); assert!(status_contains(&app.status, "Loaded default configuration")); @@ -1024,6 +1072,59 @@ mod tests { ); } + /// A confirm with nothing armed answers no question, so it must not + /// replace a draft the user was never warned about. + #[test] + fn reset_to_defaults_confirmed_without_a_request_changes_nothing() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft.capture_enabled = !app.defaults.capture_enabled; + let changed_draft = app.draft.clone(); + + let _ = app.handle_reset_to_defaults_confirmed(); + + assert_eq!(app.draft, changed_draft); + assert!(!app.defaults_reset_pending); + assert!( + !status_contains(&app.status, "Loaded default configuration"), + "an unarmed confirm reports nothing, because it did nothing" + ); + } + + /// Cancel withdraws the question and takes its warning off the status + /// line, leaving the draft exactly as the user left it. + #[test] + fn reset_to_defaults_canceled_disarms_and_clears_the_hint() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft.capture_enabled = !app.defaults.capture_enabled; + let changed_draft = app.draft.clone(); + + let _ = app.handle_reset_to_defaults_requested(); + let _ = app.handle_reset_to_defaults_canceled(); + + assert!(!app.defaults_reset_pending); + assert_eq!(app.draft, changed_draft); + assert!(matches!(app.status, StatusMessage::Idle)); + + // Nothing is armed now, so the confirm that follows it is inert. + let _ = app.handle_reset_to_defaults_confirmed(); + assert_eq!(app.draft, changed_draft); + } + + /// A cancel with nothing armed has no question to withdraw, so it must + /// not wipe the status the user is reading. + #[test] + fn reset_to_defaults_canceled_without_a_request_keeps_the_status() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.status = StatusMessage::error("Failed to load config from disk: nope"); + + let _ = app.handle_reset_to_defaults_canceled(); + + assert!(status_contains(&app.status, "Failed to load config")); + } + #[test] fn reset_to_defaults_confirmation_is_canceled_by_draft_edit() { let (mut app, _effects) = ConfiguratorApp::new_app(); @@ -1036,6 +1137,22 @@ mod tests { assert!(matches!(app.status, StatusMessage::Idle)); } + /// The disarming an edit does is what the confirm is guarded on: the + /// stale answer to a withdrawn question must not throw the edit away. + #[test] + fn a_draft_edit_between_request_and_confirm_refuses_the_confirm() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + + let _ = app.handle_reset_to_defaults_requested(); + let _ = app.handle_toggle_changed(ToggleField::CaptureEnabled, !app.draft.capture_enabled); + let edited_draft = app.draft.clone(); + let _ = app.handle_reset_to_defaults_confirmed(); + + assert_eq!(app.draft, edited_draft); + assert_ne!(app.draft, app.defaults); + } + /// The reviewer's case: the file spells `undo` out and never mentions /// `clear_canvas`, and the user then types `undo`'s shortcut into the /// Clear canvas field. The draft is the authored text now, so both lists diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index bae6c9d7..e9f99a8f 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -44,6 +44,8 @@ impl ConfiguratorApp { match message { Message::ReloadRequested => self.handle_reload_requested(), Message::ResetToDefaultsRequested => self.handle_reset_to_defaults_requested(), + Message::ResetToDefaultsConfirmed => self.handle_reset_to_defaults_confirmed(), + Message::ResetToDefaultsCanceled => self.handle_reset_to_defaults_canceled(), Message::SaveRequested => self.handle_save_requested(), Message::MigrationApplyRequested => self.handle_migration_apply_requested(), Message::MigrationDismissed => self.handle_migration_dismissed(), diff --git a/configurator/src/app/update/session_catalog.rs b/configurator/src/app/update/session_catalog.rs index 5382757d..f5f06677 100644 --- a/configurator/src/app/update/session_catalog.rs +++ b/configurator/src/app/update/session_catalog.rs @@ -214,6 +214,11 @@ impl ConfiguratorApp { if self.session_catalog.pending_clear_id.as_deref() != Some(id.as_str()) { return Vec::new(); } + // Answered, so the question is gone: the row leaves its armed state + // for the busy one in the same refresh instead of showing a Confirm + // Clear the model would now refuse. The completion path clears this + // too, which from here is redundant rather than load-bearing. + self.session_catalog.pending_clear_id = None; self.session_catalog.busy = true; self.status = StatusMessage::info("Clearing saved session data..."); vec![Effect::ClearSessionEntry { id }] diff --git a/configurator/src/app/update/session_catalog/tests.rs b/configurator/src/app/update/session_catalog/tests.rs index 23394ab1..e362a402 100644 --- a/configurator/src/app/update/session_catalog/tests.rs +++ b/configurator/src/app/update/session_catalog/tests.rs @@ -281,6 +281,93 @@ fn clear_request_sets_pending_confirmation_when_safe() { assert!(status_contains(&app.status, "Confirm Clear")); } +/// The confirm answers the question, so the question is consumed with it: +/// the armed row has nothing left to re-confirm, while the work it started — +/// the busy flag, the status, the effect — is untouched. +#[test] +fn clear_confirmed_consumes_the_pending_confirmation() { + let temp = crate::test_temp::tempdir().unwrap(); + let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog = SessionCatalogState::loading(); + app.session_catalog + .replace_items(vec![catalog_item("s-1", "Lecture")]); + app.daemon_status = Some(inactive_daemon_status()); + let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); + + let effects = app.handle_session_catalog_clear_confirmed("s-1".to_string()); + + assert!(matches!( + effects.as_slice(), + [Effect::ClearSessionEntry { id }] if id == "s-1" + )); + assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(app.session_catalog.busy); + assert!(status_contains(&app.status, "Clearing saved session data")); +} + +/// The clear is already running, so a second confirm — a double press, or a +/// stale one from a dialog — must not start it again. +#[test] +fn clear_confirmed_twice_starts_only_one_clear() { + let temp = crate::test_temp::tempdir().unwrap(); + let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog = SessionCatalogState::loading(); + app.session_catalog + .replace_items(vec![catalog_item("s-1", "Lecture")]); + app.daemon_status = Some(inactive_daemon_status()); + let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); + let _ = app.handle_session_catalog_clear_confirmed("s-1".to_string()); + + let effects = app.handle_session_catalog_clear_confirmed("s-1".to_string()); + + assert!(effects.is_empty()); + assert!(app.session_catalog.busy); + assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(status_contains(&app.status, "Clearing saved session data")); +} + +/// A confirm naming another row is not this row's answer, so it neither +/// starts a clear nor consumes the confirmation that is armed. +#[test] +fn clear_confirmed_for_another_row_leaves_the_pending_one_armed() { + let temp = crate::test_temp::tempdir().unwrap(); + let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog = SessionCatalogState::loading(); + app.session_catalog.replace_items(vec![ + catalog_item("s-1", "Lecture"), + catalog_item("s-2", "Seminar"), + ]); + app.daemon_status = Some(inactive_daemon_status()); + let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); + + let effects = app.handle_session_catalog_clear_confirmed("s-2".to_string()); + + assert!(effects.is_empty()); + assert!(!app.session_catalog.busy); + assert_eq!(app.session_catalog.pending_clear_id.as_deref(), Some("s-1")); +} + +/// Completion clears the pending id too. Nothing reaches it armed anymore, +/// but the clear stays: it is what covers every other action's completion. +#[test] +fn action_completed_still_clears_a_pending_confirmation() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog.busy = true; + app.session_catalog.pending_clear_id = Some("s-1".to_string()); + + let _ = app.handle_session_catalog_action_completed(Ok(SessionCatalogActionResult { + message: "Cleared.".to_string(), + items: vec![catalog_item("s-1", "Lecture")], + warning: false, + })); + + assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(!app.session_catalog.busy); +} + #[test] fn action_completed_replaces_catalog_items() { let (mut app, _effects) = ConfiguratorApp::new_app(); diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index f1d03783..28362233 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -54,7 +54,15 @@ pub enum CommandMessage { #[derive(Debug, Clone)] pub enum Message { ReloadRequested, + /// Asks for the reset and arms the confirmation; it never replaces the + /// draft on its own. ResetToDefaultsRequested, + /// Answers an armed confirmation with yes. Applying belongs to this + /// message alone, so the control that asks and the control that answers + /// are separate in every channel. + ResetToDefaultsConfirmed, + /// Answers an armed confirmation with no. + ResetToDefaultsCanceled, SaveRequested, MigrationApplyRequested, MigrationDismissed, From b289f9022d389796221fcb95d427dc56669133a0 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:12:17 +0200 Subject: [PATCH 2/5] fix(configurator): preserve confirmation feedback and focus --- configurator/src/app/component.rs | 6 +++++ configurator/src/app/pages/session.rs | 6 +++++ configurator/src/app/update/config.rs | 36 ++++++++++++++++++++++----- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/configurator/src/app/component.rs b/configurator/src/app/component.rs index 5e053930..faabbc74 100644 --- a/configurator/src/app/component.rs +++ b/configurator/src/app/component.rs @@ -409,9 +409,15 @@ impl Component for ConfiguratorApp { // affordances is on screen follows it: asking is offered until the // question stands, answering only while it does. let defaults_armed = self.defaults_reset_pending; + let defaults_arming = defaults_armed && !widgets.defaults_confirm_button.get_visible(); set_visible(&widgets.defaults_button, !defaults_armed); set_visible(&widgets.defaults_confirm_button, defaults_armed); set_visible(&widgets.defaults_cancel_button, defaults_armed); + if defaults_arming { + // The Defaults button just stepped aside. Keep keyboard users in + // the revealed flow instead of leaving focus on a hidden widget. + widgets.defaults_confirm_button.grab_focus(); + } // Status strip. let (status_text, status_class) = match &self.status { diff --git a/configurator/src/app/pages/session.rs b/configurator/src/app/pages/session.rs index 54b0effa..2eb80c77 100644 --- a/configurator/src/app/pages/session.rs +++ b/configurator/src/app/pages/session.rs @@ -540,9 +540,15 @@ fn item_card(item: &CatalogItemLayout, sender: &ComponentSender set_sensitive(&forget, values.actions_enabled); set_sensitive(&tool_state, values.tool_state_enabled); + let clear_arming = values.clear_armed && !confirm.get_visible(); set_visible(&clear, !values.clear_armed); set_sensitive(&clear, values.clear_enabled); set_visible(&confirm, values.clear_armed); + if clear_arming { + // The destructive action just stepped aside. Move keyboard focus + // to the revealed answer rather than leaving it hidden. + confirm_button.grab_focus(); + } }); CatalogRow { card, refresh } diff --git a/configurator/src/app/update/config.rs b/configurator/src/app/update/config.rs index 96efdeb3..5b4315c4 100644 --- a/configurator/src/app/update/config.rs +++ b/configurator/src/app/update/config.rs @@ -78,9 +78,7 @@ impl ConfiguratorApp { } self.defaults_reset_pending = true; - self.status = StatusMessage::warning( - "Defaults will replace the current draft with built-in defaults. Press \"Confirm Defaults\" to continue.", - ); + self.status = StatusMessage::warning(DEFAULTS_CONFIRMATION_HINT); Vec::new() } @@ -107,18 +105,23 @@ impl ConfiguratorApp { Vec::new() } - /// Stands the confirmation down and takes its hint off the status line. + /// Stands the confirmation down, and takes its hint off the status line + /// only while that hint is what the line still holds. /// /// Guarded on the same flag as the confirm: with nothing armed there is /// no question to withdraw, so a stray cancel must not wipe a status the - /// user is reading. + /// user is reading. Disarming and clearing are separate because another + /// operation may have replaced the hint with newer feedback while the + /// question remained open. pub(super) fn handle_reset_to_defaults_canceled(&mut self) -> Vec { if !self.defaults_reset_pending { return Vec::new(); } self.defaults_reset_pending = false; - self.status = StatusMessage::idle(); + if is_defaults_confirmation_hint(&self.status) { + self.status = StatusMessage::idle(); + } Vec::new() } @@ -343,6 +346,12 @@ impl ConfiguratorApp { const SHOWN_DIAGNOSTICS: usize = 8; +const DEFAULTS_CONFIRMATION_HINT: &str = "Defaults will replace the current draft with built-in defaults. Press \"Confirm Defaults\" to continue."; + +fn is_defaults_confirmation_hint(status: &StatusMessage) -> bool { + matches!(status, StatusMessage::Warning(text) if text == DEFAULTS_CONFIRMATION_HINT) +} + /// Why a save was refused before it began. /// /// The count is all the banner can give: which rows are at fault is the row's @@ -1112,6 +1121,21 @@ mod tests { assert_eq!(app.draft, changed_draft); } + #[test] + fn reset_to_defaults_canceled_keeps_status_that_replaced_the_hint() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + + let _ = app.handle_reset_to_defaults_requested(); + app.status = StatusMessage::error("Failed to clear session s-1: nope"); + + let _ = app.handle_reset_to_defaults_canceled(); + + assert!(!app.defaults_reset_pending); + assert!(matches!(app.status, StatusMessage::Error(_))); + assert!(status_contains(&app.status, "Failed to clear session s-1")); + } + /// A cancel with nothing armed has no question to withdraw, so it must /// not wipe the status the user is reading. #[test] From c68c76fcee09249866152374c739a8ac6e170f58 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:15:24 +0200 Subject: [PATCH 3/5] fix(packaging): fail AUR updates before partial publication --- .github/workflows/build-packages.yml | 2 +- tools/README.md | 2 + tools/test-release-packaging.sh | 182 ++++++++++++++++++++++++++- tools/update-aur-from-manifest.sh | 95 ++++++++++++-- 4 files changed, 268 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index 6699738d..4ccb75e0 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -413,7 +413,7 @@ jobs: run: | GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber.git aur-wayscriber GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber-bin.git aur-wayscriber-bin - GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber-configurator.git aur-wayscriber-configurator || true + GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber-configurator.git aur-wayscriber-configurator - name: Update AUR from manifest env: diff --git a/tools/README.md b/tools/README.md index 22796256..5c121996 100644 --- a/tools/README.md +++ b/tools/README.md @@ -138,6 +138,8 @@ hashes, so build-level changes still need a pull request from us. See - **update-aur-from-manifest.sh** - CI-friendly AUR update - Updates multiple AUR packages using checksums from manifest.json - Designed for CI automation after artifacts are built + - Requires the configurator AUR clone unless `--no-configurator` is passed explicitly + - Supports `--source-sha256` for offline/recovery runs - Usage: `./tools/update-aur-from-manifest.sh --version --manifest dist/manifest.json --push` --- diff --git a/tools/test-release-packaging.sh b/tools/test-release-packaging.sh index 1aebb12f..bc32753d 100755 --- a/tools/test-release-packaging.sh +++ b/tools/test-release-packaging.sh @@ -775,7 +775,8 @@ bash "${REPO_ROOT}/tools/update-aur-from-manifest.sh" \ --manifest "${MANIFEST}" \ --source-dir "${WORK_DIR}/missing-source" \ --bin-dir "${AUR_BIN_DIR}" \ - --config-dir "${WORK_DIR}/missing-config" >/dev/null + --config-dir "${WORK_DIR}/missing-config" \ + --no-configurator >/dev/null assert_contains "${AUR_BIN_DIR}/PKGBUILD" "'gtk4'" assert_contains "${AUR_BIN_DIR}/.SRCINFO" "depends = gtk4" @@ -790,4 +791,183 @@ assert_contains "${AUR_BIN_DIR}/PKGBUILD" 'install -Dm644 "${srcdir_tmp}/usr/sha assert_contains "${REPO_ROOT}/packaging/PKGBUILD" "'gtk4-layer-shell'" assert_contains "${REPO_ROOT}/packaging/.SRCINFO" "depends = gtk4-layer-shell" +# The configurator AUR channel is required by default. The hosted clone step +# must fail before the updater runs rather than silently publish two channels. +assert_not_contains "${RELEASE_WORKFLOW}" \ + "git clone ssh://aur@aur.archlinux.org/wayscriber-configurator.git aur-wayscriber-configurator || true" + +AUR_SOURCE_SHA='abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789' +AUR_FAKE_BIN="${WORK_DIR}/aur-fake-bin" +mkdir -p "${AUR_FAKE_BIN}" +cat > "${AUR_FAKE_BIN}/curl" <<'EOF' +#!/usr/bin/env bash +echo 'AUR_TEST_NETWORK_ACCESS' >&2 +exit 97 +EOF +chmod +x "${AUR_FAKE_BIN}/curl" + +write_source_clone() { + local dir="$1" pkgver="$2" pkgrel="$3" + mkdir -p "${dir}" + cat > "${dir}/PKGBUILD" < "${dir}/.SRCINFO" < "${dir}/PKGBUILD" < "${dir}/.SRCINFO" <"${output}" 2>&1 + local status=$? + set -e + if [[ ${status} -eq 0 ]]; then + echo "Expected AUR updater failure containing: ${expected}" >&2 + exit 1 + fi + assert_contains "${output}" "${expected}" +} + +# Relative clone paths used to be resolved after pushd, turning `dir/PKGBUILD` +# into `dir/dir/PKGBUILD` and resetting same-version hotfixes to pkgrel=1. +AUR_SOURCE_HOTFIX="${WORK_DIR}/aur-source-hotfix" +write_source_clone "${AUR_SOURCE_HOTFIX}" 9.9.9 3 +run_aur_updater "${WORK_DIR}" \ + --source-dir aur-source-hotfix \ + --bin-dir missing-bin \ + --config-dir missing-config \ + --no-configurator \ + --source-sha256 "${AUR_SOURCE_SHA}" >/dev/null +assert_contains "${AUR_SOURCE_HOTFIX}/PKGBUILD" "pkgrel=4" +assert_contains "${AUR_SOURCE_HOTFIX}/.SRCINFO" "pkgrel = 4" + +AUR_CONFIG_HOTFIX="${WORK_DIR}/aur-config-hotfix" +write_configurator_clone "${AUR_CONFIG_HOTFIX}" 9.9.9 3 +run_aur_updater "${WORK_DIR}" \ + --source-dir missing-source \ + --bin-dir missing-bin \ + --config-dir aur-config-hotfix \ + --source-sha256 "${AUR_SOURCE_SHA}" >/dev/null +assert_contains "${AUR_CONFIG_HOTFIX}/PKGBUILD" "pkgrel=4" +assert_contains "${AUR_CONFIG_HOTFIX}/.SRCINFO" "pkgrel = 4" + +# A missing required configurator clone aborts before an earlier source channel +# is touched. Skipping it remains available, but only as an explicit decision. +AUR_PREFLIGHT_SOURCE="${WORK_DIR}/aur-preflight-source" +write_source_clone "${AUR_PREFLIGHT_SOURCE}" 9.9.9 3 +cp "${AUR_PREFLIGHT_SOURCE}/PKGBUILD" "${WORK_DIR}/aur-preflight-source.before" +expect_aur_failure "wayscriber-configurator AUR clone not found" "${WORK_DIR}" \ + --source-dir aur-preflight-source \ + --bin-dir missing-bin \ + --config-dir missing-config \ + --source-sha256 "${AUR_SOURCE_SHA}" +cmp "${WORK_DIR}/aur-preflight-source.before" "${AUR_PREFLIGHT_SOURCE}/PKGBUILD" + +AUR_SKIP_OUTPUT="${WORK_DIR}/aur-skip-output" +run_aur_updater "${WORK_DIR}" \ + --source-dir missing-source \ + --bin-dir missing-bin \ + --config-dir missing-config \ + --no-configurator >"${AUR_SKIP_OUTPUT}" 2>&1 +assert_contains "${AUR_SKIP_OUTPUT}" "--no-configurator was passed" + +# Download and checksum validation also run before mutation. The fake curl +# makes accidental network use deterministic and proves the clone stays intact. +AUR_CHECKSUM_SOURCE="${WORK_DIR}/aur-checksum-source" +write_source_clone "${AUR_CHECKSUM_SOURCE}" 9.9.8 2 +cp "${AUR_CHECKSUM_SOURCE}/PKGBUILD" "${WORK_DIR}/aur-checksum-source.before" +expect_aur_failure "Failed to download the source archive" "${WORK_DIR}" \ + --source-dir aur-checksum-source \ + --bin-dir missing-bin \ + --config-dir missing-config \ + --no-configurator +cmp "${WORK_DIR}/aur-checksum-source.before" "${AUR_CHECKSUM_SOURCE}/PKGBUILD" + +expect_aur_failure "Source archive checksum is not a sha256 digest" "${WORK_DIR}" \ + --source-dir aur-checksum-source \ + --bin-dir missing-bin \ + --config-dir missing-config \ + --no-configurator \ + --source-sha256 invalid + echo "Release packaging contract checks passed." diff --git a/tools/update-aur-from-manifest.sh b/tools/update-aur-from-manifest.sh index 8fd3e061..200f0971 100644 --- a/tools/update-aur-from-manifest.sh +++ b/tools/update-aur-from-manifest.sh @@ -31,6 +31,8 @@ AUR_SOURCE_DIR="${AUR_SOURCE_DIR:-${REPO_ROOT}/../aur-wayscriber}" AUR_BIN_DIR="${AUR_BIN_DIR:-${REPO_ROOT}/../aur-wayscriber-bin}" AUR_CONFIG_DIR="${AUR_CONFIG_DIR:-${REPO_ROOT}/../aur-wayscriber-configurator}" DO_PUSH=0 +NO_CONFIGURATOR=0 +SOURCE_ARCHIVE_SHA="${AUR_SOURCE_ARCHIVE_SHA256:-}" usage() { cat <<'EOF' @@ -43,8 +45,13 @@ Flags: --source-dir wayscriber AUR repo path --bin-dir wayscriber-bin AUR repo path --config-dir wayscriber-configurator AUR repo path + --no-configurator Deliberately skip the configurator AUR channel + --source-sha256 Use this source archive checksum without downloading --push Git add/commit/push changes (default: dry-run) -h, --help Show this help + +The configurator channel is required unless --no-configurator is passed. The +checksum can also be supplied through AUR_SOURCE_ARCHIVE_SHA256. EOF } @@ -55,6 +62,8 @@ while [[ $# -gt 0 ]]; do --source-dir) AUR_SOURCE_DIR="$2"; shift 2 ;; --bin-dir) AUR_BIN_DIR="$2"; shift 2 ;; --config-dir) AUR_CONFIG_DIR="$2"; shift 2 ;; + --no-configurator) NO_CONFIGURATOR=1; shift ;; + --source-sha256) SOURCE_ARCHIVE_SHA="$2"; shift 2 ;; --push) DO_PUSH=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "Unknown arg: $1" >&2; usage; exit 1 ;; @@ -88,10 +97,23 @@ read_pkgrel_from_pkgbuild() { fi } -next_pkgrel() { +absolute_dir() { local dir="$1" - local pkgfile="$dir/PKGBUILD" - local current_pkgver current_pkgrel + (cd "$dir" >/dev/null 2>&1 && pwd -P) || { + echo "Not a directory: $dir" >&2 + return 1 + } +} + +next_pkgrel() { + local dir_abs pkgfile current_pkgver current_pkgrel + dir_abs="$(absolute_dir "$1")" + pkgfile="${dir_abs}/PKGBUILD" + + [[ -f "$pkgfile" ]] || { + echo "Missing AUR recipe: $pkgfile" >&2 + return 1 + } current_pkgver="$(read_pkgver_from_pkgbuild "$pkgfile")" current_pkgrel="$(read_pkgrel_from_pkgbuild "$pkgfile")" @@ -108,24 +130,50 @@ sha_for() { jq -r --arg n "$name" '.artifacts[] | select(.name==$n) | .sha256' "$MANIFEST" } -SOURCE_ARCHIVE_SHA="" - source_archive_url() { printf 'https://github.com/devmobasa/wayscriber/archive/refs/tags/v%s.tar.gz' "$VERSION" } source_archive_sha() { if [[ -z "$SOURCE_ARCHIVE_SHA" ]]; then - local tmp + local tmp url + url="$(source_archive_url)" tmp="$(mktemp)" - curl -fsSL "$(source_archive_url)" -o "$tmp" + if ! curl -fsSL "$url" -o "$tmp"; then + rm -f "$tmp" + echo "Failed to download the source archive: ${url}" >&2 + echo "Pass --source-sha256 or set AUR_SOURCE_ARCHIVE_SHA256 to supply it directly." >&2 + return 1 + fi + if [[ ! -s "$tmp" ]]; then + rm -f "$tmp" + echo "Downloaded an empty source archive from ${url}" >&2 + return 1 + fi SOURCE_ARCHIVE_SHA="$(sha256sum "$tmp" | awk '{print $1}')" rm -f "$tmp" fi + if [[ ! "$SOURCE_ARCHIVE_SHA" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "Source archive checksum is not a sha256 digest: '${SOURCE_ARCHIVE_SHA}'" >&2 + return 1 + fi + printf '%s' "$SOURCE_ARCHIVE_SHA" } +require_recipe() { + local channel="$1" dir="$2" + [[ -d "$dir" ]] || { + echo "${channel} AUR clone not found: $dir" >&2 + return 1 + } + [[ -f "$dir/PKGBUILD" && -f "$dir/.SRCINFO" ]] || { + echo "${channel} AUR clone is missing PKGBUILD or .SRCINFO: $dir" >&2 + return 1 + } +} + replace_line() { local file="$1" pattern="$2" replacement="$3" perl -0pi -e 'BEGIN { our ($pattern, $replacement) = splice @ARGV, 0, 2 } s/$pattern/$replacement/mg' \ @@ -285,8 +333,8 @@ update_source() { local dir="$1" local pkgrel [[ -d "$dir" ]] || { echo "Skip source: $dir not found" >&2; return; } - pushd "$dir" >/dev/null pkgrel="$(next_pkgrel "$dir")" + pushd "$dir" >/dev/null local source_sha source_url source_sha="$(source_archive_sha)" source_url="$(source_archive_url)" @@ -320,9 +368,9 @@ update_source() { update_configurator() { local dir="$1" local pkgrel - [[ -d "$dir" ]] || { echo "Skip configurator: $dir not found" >&2; return; } - pushd "$dir" >/dev/null + require_recipe "wayscriber-configurator" "$dir" pkgrel="$(next_pkgrel "$dir")" + pushd "$dir" >/dev/null local source_sha source_url source_sha="$(source_archive_sha)" source_url="$(source_archive_url)" @@ -352,6 +400,31 @@ update_configurator() { echo "Updating AUR using manifest: ${MANIFEST}" echo "Version: ${VERSION}" + +# The configurator is a release channel, not a best-effort extra. Check all +# known late failures before any earlier channel can commit and push. +if [[ "$NO_CONFIGURATOR" -eq 0 ]]; then + require_recipe "wayscriber-configurator" "$AUR_CONFIG_DIR" +fi +if [[ -d "$AUR_SOURCE_DIR" ]]; then + require_recipe "wayscriber" "$AUR_SOURCE_DIR" +fi +if [[ -d "$AUR_BIN_DIR" ]]; then + require_recipe "wayscriber-bin" "$AUR_BIN_DIR" + bin_sha="$(sha_for "wayscriber-v${VERSION}-linux-x86_64.tar.gz")" + [[ -n "$bin_sha" && "$bin_sha" != "null" ]] || { + echo "Bin checksum missing in manifest" >&2 + exit 1 + } +fi +if [[ -d "$AUR_SOURCE_DIR" || ( "$NO_CONFIGURATOR" -eq 0 && -d "$AUR_CONFIG_DIR" ) ]]; then + SOURCE_ARCHIVE_SHA="$(source_archive_sha)" +fi + update_source "${AUR_SOURCE_DIR}" update_bin "${AUR_BIN_DIR}" -update_configurator "${AUR_CONFIG_DIR}" +if [[ "$NO_CONFIGURATOR" -eq 1 ]]; then + echo "Skipping wayscriber-configurator: --no-configurator was passed." >&2 +else + update_configurator "${AUR_CONFIG_DIR}" +fi From f4f8b3185268e03c4cf35f43cebf0fb0500305b2 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:16:10 +0200 Subject: [PATCH 4/5] fix(packaging): declare configurator libadwaita floor --- .github/workflows/build-packages.yml | 9 ++++++++- packaging/package.configurator.yaml | 6 ++++-- tools/test-release-packaging.sh | 8 ++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index 4ccb75e0..945ddd03 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -138,6 +138,7 @@ jobs: grep -Fq '/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell' <<< "$deb_files" configurator_deb_depends="$(dpkg-deb -f dist/wayscriber-configurator-amd64.deb Depends)" grep -Fq 'libc6 (>= 2.39)' <<< "$configurator_deb_depends" + grep -Fq 'libadwaita-1-0 (>= 1.4)' <<< "$configurator_deb_depends" mkdir -p "$RUNNER_TEMP/rpmdb" test "$(rpm --dbpath "$RUNNER_TEMP/rpmdb" -qp --qf '%{VERSION}-%{RELEASE}\n' dist/wayscriber-x86_64.rpm)" = "$expected_package_version" @@ -150,6 +151,7 @@ jobs: grep -Fxq '/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell' <<< "$rpm_files" configurator_rpm_requires="$(rpm --dbpath "$RUNNER_TEMP/rpmdb" -qp --requires dist/wayscriber-configurator-x86_64.rpm)" grep -Fxq 'glibc >= 2.39' <<< "$configurator_rpm_requires" + grep -Fxq 'libadwaita >= 1.4' <<< "$configurator_rpm_requires" tar_files="$(tar -tzf "dist/wayscriber-v${{ steps.meta.outputs.version }}-linux-x86_64.tar.gz")" grep -Eq '/usr/bin/wayscriber$' <<< "$tar_files" @@ -164,8 +166,13 @@ jobs: ubuntu:24.04 \ bash -euxo pipefail -c ' apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y /dist/wayscriber-amd64.deb + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + /dist/wayscriber-amd64.deb \ + /dist/wayscriber-configurator-amd64.deb wayscriber --version + # Installing proves the configurator runtime dependencies resolve + # on the supported LTS. Do not launch this foreground GUI here. + test -x /usr/bin/wayscriber-configurator ' - name: Upload tarball diff --git a/packaging/package.configurator.yaml b/packaging/package.configurator.yaml index d06d3f32..c106ef69 100644 --- a/packaging/package.configurator.yaml +++ b/packaging/package.configurator.yaml @@ -74,7 +74,8 @@ overrides: - libpangocairo-1.0-0 - libxkbcommon0 - libgtk-4-1 - - libadwaita-1-0 + # Keep this aligned with the libadwaita feature floor in Cargo.toml. + - libadwaita-1-0 (>= 1.4) - libstdc++6 rpm: depends: @@ -85,7 +86,8 @@ overrides: - pango - libxkbcommon - gtk4 - - libadwaita + # Keep this aligned with the libadwaita feature floor in Cargo.toml. + - libadwaita >= 1.4 - libstdc++ rpm: arch: x86_64 diff --git a/tools/test-release-packaging.sh b/tools/test-release-packaging.sh index bc32753d..df69869d 100755 --- a/tools/test-release-packaging.sh +++ b/tools/test-release-packaging.sh @@ -45,6 +45,8 @@ assert_not_contains "${WORK_DIR}/package-overrides.yml" "- libgtk4-layer-shell0" assert_not_contains "${WORK_DIR}/package-overrides.yml" "- gtk4-layer-shell" assert_contains "${CONFIGURATOR_PACKAGE_CONFIG}" "- libc6 (>= 2.39)" assert_contains "${CONFIGURATOR_PACKAGE_CONFIG}" "- glibc >= 2.39" +assert_contains "${CONFIGURATOR_PACKAGE_CONFIG}" "- libadwaita-1-0 (>= 1.4)" +assert_contains "${CONFIGURATOR_PACKAGE_CONFIG}" "- libadwaita >= 1.4" # Release jobs must not silently raise the glibc floor when ubuntu-latest # changes; the package job is the binary floor's defining runner. @@ -59,6 +61,12 @@ assert_contains "${WORK_DIR}/release-package-job.yml" "'%{VERSION}-%{RELEASE}\\n assert_contains "${WORK_DIR}/release-package-job.yml" "grep -Eq '/usr/bin/wayscriber$'" assert_contains "${WORK_DIR}/release-package-job.yml" 'wayscriber-configurator-v${{ steps.meta.outputs.version }}-linux-x86_64.tar.gz' assert_contains "${WORK_DIR}/release-package-job.yml" "grep -Eq '/usr/bin/wayscriber-configurator$'" +assert_contains "${WORK_DIR}/release-package-job.yml" \ + "grep -Fq 'libadwaita-1-0 (>= 1.4)' <<< \"\$configurator_deb_depends\"" +assert_contains "${WORK_DIR}/release-package-job.yml" \ + "grep -Fxq 'libadwaita >= 1.4' <<< \"\$configurator_rpm_requires\"" +assert_contains "${WORK_DIR}/release-package-job.yml" \ + "/dist/wayscriber-configurator-amd64.deb" assert_contains "${WORK_DIR}/release-package-job.yml" "Check direct Arch installer compatibility" assert_contains "${WORK_DIR}/release-package-job.yml" "https://wayscriber.com/arch-install.sh" assert_contains "${WORK_DIR}/release-package-job.yml" "./tools/check-arch-installer-manifest.sh" From ea43a29f5fdd5e4b904d5f7de769d9a181ce1f80 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:42:03 +0200 Subject: [PATCH 5/5] fix: harden configurator confirmations and AUR releases --- configurator/src/app/component.rs | 1 + configurator/src/app/pages/session.rs | 6 +- configurator/src/app/startup.rs | 8 +- configurator/src/app/state.rs | 42 +++ configurator/src/app/update/config.rs | 23 +- configurator/src/app/update/mod.rs | 4 +- .../src/app/update/session_catalog.rs | 27 +- .../src/app/update/session_catalog/tests.rs | 81 ++++- configurator/src/messages.rs | 2 +- packaging/.SRCINFO | 2 +- packaging/PKGBUILD | 2 +- tools/README.md | 1 + tools/test-release-packaging.sh | 251 ++++++++++++++- tools/update-aur-from-manifest.sh | 297 +++++++++++++++--- 14 files changed, 651 insertions(+), 96 deletions(-) diff --git a/configurator/src/app/component.rs b/configurator/src/app/component.rs index faabbc74..7c538d80 100644 --- a/configurator/src/app/component.rs +++ b/configurator/src/app/component.rs @@ -426,6 +426,7 @@ impl Component for ConfiguratorApp { StatusMessage::Success(text) => (text.as_str(), Some("success")), StatusMessage::Warning(text) => (text.as_str(), Some("warning")), StatusMessage::Error(text) => (text.as_str(), Some("error")), + StatusMessage::Confirmation(prompt) => (prompt.message(), Some("warning")), }; if widgets.status_label.text() != status_text { widgets.status_label.set_text(status_text); diff --git a/configurator/src/app/pages/session.rs b/configurator/src/app/pages/session.rs index 2eb80c77..0690e9a5 100644 --- a/configurator/src/app/pages/session.rs +++ b/configurator/src/app/pages/session.rs @@ -509,7 +509,11 @@ fn item_card(item: &CatalogItemLayout, sender: &ComponentSender ); confirm_button.add_css_class("destructive-action"); confirm.append(&confirm_button); - let cancel_button = message_button("Cancel", sender, Message::SessionCatalogClearCanceled); + let cancel_button = message_button( + "Cancel", + sender, + Message::SessionCatalogClearCanceled(item.id.clone()), + ); cancel_button.add_css_class("flat"); confirm.append(&cancel_button); danger.append(&confirm); diff --git a/configurator/src/app/startup.rs b/configurator/src/app/startup.rs index 1087254a..f4062841 100644 --- a/configurator/src/app/startup.rs +++ b/configurator/src/app/startup.rs @@ -110,13 +110,7 @@ mod tests { use crate::test_temp::{TempDir, tempdir}; fn status_text(status: &StatusMessage) -> String { - match status { - StatusMessage::Info(text) - | StatusMessage::Success(text) - | StatusMessage::Error(text) - | StatusMessage::Warning(text) => text.clone(), - StatusMessage::Idle => String::new(), - } + status.text().unwrap_or_default().to_string() } fn args(values: &[&str]) -> Vec { diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index f2834598..3c40b175 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -71,6 +71,25 @@ pub(crate) struct ConfiguratorApp { pub(crate) startup_request: StartupRequest, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConfirmationPrompt { + DefaultsReset, + SessionClear, +} + +impl ConfirmationPrompt { + pub(crate) fn message(self) -> &'static str { + match self { + ConfirmationPrompt::DefaultsReset => { + "Defaults will replace the current draft with built-in defaults. Press \"Confirm Defaults\" to continue." + } + ConfirmationPrompt::SessionClear => { + "Clear saved data removes the selected session primary and non-lock sidecars. Press Confirm Clear to continue." + } + } + } +} + #[derive(Debug, Clone)] pub(crate) enum StatusMessage { Idle, @@ -78,6 +97,7 @@ pub(crate) enum StatusMessage { Success(String), Error(String), Warning(String), + Confirmation(ConfirmationPrompt), } impl StatusMessage { @@ -101,6 +121,25 @@ impl StatusMessage { StatusMessage::Warning(message.into()) } + pub(crate) fn confirmation(prompt: ConfirmationPrompt) -> Self { + StatusMessage::Confirmation(prompt) + } + + pub(crate) fn is_confirmation(&self, prompt: ConfirmationPrompt) -> bool { + matches!(self, StatusMessage::Confirmation(current) if *current == prompt) + } + + pub(crate) fn text(&self) -> Option<&str> { + match self { + StatusMessage::Idle => None, + StatusMessage::Info(text) + | StatusMessage::Success(text) + | StatusMessage::Error(text) + | StatusMessage::Warning(text) => Some(text.as_str()), + StatusMessage::Confirmation(prompt) => Some(prompt.message()), + } + } + /// Adds a sentence without discarding what is already there. /// /// The load status can be carrying this file's diagnostics, and a note @@ -111,6 +150,9 @@ impl StatusMessage { StatusMessage::Info(text) | StatusMessage::Success(text) | StatusMessage::Warning(text) => StatusMessage::warning(format!("{text}\n{note}")), + StatusMessage::Confirmation(prompt) => { + StatusMessage::warning(format!("{}\n{note}", prompt.message())) + } // A failed load is the more urgent of the two; keep its styling. StatusMessage::Error(text) => StatusMessage::error(format!("{text}\n{note}")), } diff --git a/configurator/src/app/update/config.rs b/configurator/src/app/update/config.rs index 5b4315c4..82c62a83 100644 --- a/configurator/src/app/update/config.rs +++ b/configurator/src/app/update/config.rs @@ -8,7 +8,7 @@ use crate::models::error::FormError; use crate::models::{ConfigDraft, KeybindingField}; use super::super::effects::Effect; -use super::super::state::{ConfiguratorApp, StatusMessage}; +use super::super::state::{ConfiguratorApp, ConfirmationPrompt, StatusMessage}; impl ConfiguratorApp { pub(super) fn handle_config_loaded( @@ -78,7 +78,7 @@ impl ConfiguratorApp { } self.defaults_reset_pending = true; - self.status = StatusMessage::warning(DEFAULTS_CONFIRMATION_HINT); + self.status = StatusMessage::confirmation(ConfirmationPrompt::DefaultsReset); Vec::new() } @@ -119,7 +119,10 @@ impl ConfiguratorApp { } self.defaults_reset_pending = false; - if is_defaults_confirmation_hint(&self.status) { + if self + .status + .is_confirmation(ConfirmationPrompt::DefaultsReset) + { self.status = StatusMessage::idle(); } Vec::new() @@ -346,12 +349,6 @@ impl ConfiguratorApp { const SHOWN_DIAGNOSTICS: usize = 8; -const DEFAULTS_CONFIRMATION_HINT: &str = "Defaults will replace the current draft with built-in defaults. Press \"Confirm Defaults\" to continue."; - -fn is_defaults_confirmation_hint(status: &StatusMessage) -> bool { - matches!(status, StatusMessage::Warning(text) if text == DEFAULTS_CONFIRMATION_HINT) -} - /// Why a save was refused before it began. /// /// The count is all the banner can give: which rows are at fault is the row's @@ -547,13 +544,7 @@ mod tests { use crate::test_temp::TempDir; fn status_contains(status: &StatusMessage, needle: &str) -> bool { - match status { - StatusMessage::Info(text) - | StatusMessage::Success(text) - | StatusMessage::Error(text) - | StatusMessage::Warning(text) => text.contains(needle), - StatusMessage::Idle => false, - } + status.text().is_some_and(|text| text.contains(needle)) } static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index e9f99a8f..61d6afba 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -89,7 +89,9 @@ impl ConfiguratorApp { Message::SessionCatalogClearConfirmed(id) => { self.handle_session_catalog_clear_confirmed(id) } - Message::SessionCatalogClearCanceled => self.handle_session_catalog_clear_canceled(), + Message::SessionCatalogClearCanceled(id) => { + self.handle_session_catalog_clear_canceled(id) + } Message::SearchChanged(value) => self.handle_search_changed(value), Message::SearchCleared => self.handle_search_cleared(), Message::SearchFocusRequested => self.handle_search_focus_requested(), diff --git a/configurator/src/app/update/session_catalog.rs b/configurator/src/app/update/session_catalog.rs index f5f06677..fa618d75 100644 --- a/configurator/src/app/update/session_catalog.rs +++ b/configurator/src/app/update/session_catalog.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use crate::models::{SessionCatalogActionResult, SessionCatalogItem, SessionCatalogOperation}; use super::super::effects::Effect; -use super::super::state::{ConfiguratorApp, StatusMessage}; +use super::super::state::{ConfiguratorApp, ConfirmationPrompt, StatusMessage}; impl ConfiguratorApp { pub(super) fn handle_session_catalog_loaded( @@ -201,9 +201,7 @@ impl ConfiguratorApp { return Vec::new(); } self.session_catalog.pending_clear_id = Some(id); - self.status = StatusMessage::warning( - "Clear saved data removes the selected session primary and non-lock sidecars. Press Confirm Clear to continue.", - ); + self.status = StatusMessage::confirmation(ConfirmationPrompt::SessionClear); Vec::new() } @@ -224,9 +222,18 @@ impl ConfiguratorApp { vec![Effect::ClearSessionEntry { id }] } - pub(super) fn handle_session_catalog_clear_canceled(&mut self) -> Vec { + pub(super) fn handle_session_catalog_clear_canceled(&mut self, id: String) -> Vec { + if self.session_catalog.pending_clear_id.as_deref() != Some(id.as_str()) { + return Vec::new(); + } + self.session_catalog.pending_clear_id = None; - self.status = StatusMessage::idle(); + if self + .status + .is_confirmation(ConfirmationPrompt::SessionClear) + { + self.status = StatusMessage::idle(); + } Vec::new() } @@ -253,13 +260,7 @@ impl ConfiguratorApp { } fn status_text(&self) -> Option<&str> { - match &self.status { - StatusMessage::Info(message) - | StatusMessage::Success(message) - | StatusMessage::Error(message) - | StatusMessage::Warning(message) => Some(message.as_str()), - StatusMessage::Idle => None, - } + self.status.text() } } diff --git a/configurator/src/app/update/session_catalog/tests.rs b/configurator/src/app/update/session_catalog/tests.rs index e362a402..04558e03 100644 --- a/configurator/src/app/update/session_catalog/tests.rs +++ b/configurator/src/app/update/session_catalog/tests.rs @@ -77,13 +77,7 @@ fn inactive_daemon_status() -> crate::models::DaemonRuntimeStatus { } fn status_contains(status: &StatusMessage, needle: &str) -> bool { - match status { - StatusMessage::Info(text) - | StatusMessage::Success(text) - | StatusMessage::Error(text) - | StatusMessage::Warning(text) => text.contains(needle), - StatusMessage::Idle => false, - } + status.text().is_some_and(|text| text.contains(needle)) } #[test] @@ -281,12 +275,79 @@ fn clear_request_sets_pending_confirmation_when_safe() { assert!(status_contains(&app.status, "Confirm Clear")); } +#[test] +fn clear_canceled_disarms_and_clears_its_confirmation_status() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog = SessionCatalogState::loading(); + app.session_catalog + .replace_items(vec![catalog_item("s-1", "Lecture")]); + app.daemon_status = Some(inactive_daemon_status()); + let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); + + let effects = app.handle_session_catalog_clear_canceled("s-1".to_string()); + + assert!(effects.is_empty()); + assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(matches!(app.status, StatusMessage::Idle)); +} + +#[test] +fn clear_canceled_preserves_status_that_replaced_its_confirmation() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog = SessionCatalogState::loading(); + app.session_catalog + .replace_items(vec![catalog_item("s-1", "Lecture")]); + app.daemon_status = Some(inactive_daemon_status()); + let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); + app.status = StatusMessage::error("A newer session operation failed"); + + let _ = app.handle_session_catalog_clear_canceled("s-1".to_string()); + + assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(matches!(app.status, StatusMessage::Error(_))); + assert!(status_contains( + &app.status, + "newer session operation failed" + )); +} + +#[test] +fn stray_clear_cancel_preserves_unrelated_status() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog.pending_clear_id = None; + app.status = StatusMessage::success("A completed operation"); + + let _ = app.handle_session_catalog_clear_canceled("s-1".to_string()); + + assert!(matches!(app.status, StatusMessage::Success(_))); + assert!(status_contains(&app.status, "completed operation")); +} + +#[test] +fn stale_clear_cancel_does_not_disarm_a_newer_confirmation() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog = SessionCatalogState::loading(); + app.session_catalog.replace_items(vec![ + catalog_item("s-1", "Lecture"), + catalog_item("s-2", "Workshop"), + ]); + app.daemon_status = Some(inactive_daemon_status()); + let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); + let _ = app.handle_session_catalog_clear_requested("s-2".to_string()); + + let effects = app.handle_session_catalog_clear_canceled("s-1".to_string()); + + assert!(effects.is_empty()); + assert_eq!(app.session_catalog.pending_clear_id.as_deref(), Some("s-2")); + assert!(status_contains(&app.status, "Confirm Clear")); +} + /// The confirm answers the question, so the question is consumed with it: /// the armed row has nothing left to re-confirm, while the work it started — /// the busy flag, the status, the effect — is untouched. #[test] fn clear_confirmed_consumes_the_pending_confirmation() { - let temp = crate::test_temp::tempdir().unwrap(); + let temp = crate::test_temp::tempdir().expect("temporary test directory"); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); @@ -310,7 +371,7 @@ fn clear_confirmed_consumes_the_pending_confirmation() { /// stale one from a dialog — must not start it again. #[test] fn clear_confirmed_twice_starts_only_one_clear() { - let temp = crate::test_temp::tempdir().unwrap(); + let temp = crate::test_temp::tempdir().expect("temporary test directory"); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); @@ -332,7 +393,7 @@ fn clear_confirmed_twice_starts_only_one_clear() { /// starts a clear nor consumes the confirmation that is armed. #[test] fn clear_confirmed_for_another_row_leaves_the_pending_one_armed() { - let temp = crate::test_temp::tempdir().unwrap(); + let temp = crate::test_temp::tempdir().expect("temporary test directory"); let _env = RuntimeEnvGuard::set_xdg_runtime_dir(temp.path()); let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog = SessionCatalogState::loading(); diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index 28362233..9867edf4 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -80,7 +80,7 @@ pub enum Message { SessionCatalogClearToolStateRequested(String), SessionCatalogClearRequested(String), SessionCatalogClearConfirmed(String), - SessionCatalogClearCanceled, + SessionCatalogClearCanceled(String), SearchChanged(String), SearchCleared, SearchFocusRequested, diff --git a/packaging/.SRCINFO b/packaging/.SRCINFO index 540bdf09..60d6d319 100644 --- a/packaging/.SRCINFO +++ b/packaging/.SRCINFO @@ -15,7 +15,7 @@ pkgbase = wayscriber depends = glibc depends = gtk4 depends = gtk4-layer-shell - depends = libadwaita + depends = libadwaita>=1.4 depends = wl-clipboard depends = grim depends = slurp diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index e18afea1..6b01e7c8 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -15,7 +15,7 @@ depends=( 'glibc' 'gtk4' 'gtk4-layer-shell' - 'libadwaita' + 'libadwaita>=1.4' 'wl-clipboard' 'grim' 'slurp' diff --git a/tools/README.md b/tools/README.md index 5c121996..2998df1b 100644 --- a/tools/README.md +++ b/tools/README.md @@ -140,6 +140,7 @@ hashes, so build-level changes still need a pull request from us. See - Designed for CI automation after artifacts are built - Requires the configurator AUR clone unless `--no-configurator` is passed explicitly - Supports `--source-sha256` for offline/recovery runs + - Previews and validates every selected recipe in an isolated copy before modifying checkouts, then pushes sequentially - Usage: `./tools/update-aur-from-manifest.sh --version --manifest dist/manifest.json --push` --- diff --git a/tools/test-release-packaging.sh b/tools/test-release-packaging.sh index df69869d..fe6d4962 100755 --- a/tools/test-release-packaging.sh +++ b/tools/test-release-packaging.sh @@ -798,6 +798,8 @@ assert_contains "${AUR_BIN_DIR}/PKGBUILD" 'install -Dm644 "${srcdir_tmp}/usr/sha assert_contains "${REPO_ROOT}/packaging/PKGBUILD" "'gtk4-layer-shell'" assert_contains "${REPO_ROOT}/packaging/.SRCINFO" "depends = gtk4-layer-shell" +assert_contains "${REPO_ROOT}/packaging/PKGBUILD" "'libadwaita>=1.4'" +assert_contains "${REPO_ROOT}/packaging/.SRCINFO" "depends = libadwaita>=1.4" # The configurator AUR channel is required by default. The hosted clone step # must fail before the updater runs rather than silently publish two channels. @@ -806,13 +808,61 @@ assert_not_contains "${RELEASE_WORKFLOW}" \ AUR_SOURCE_SHA='abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789' AUR_FAKE_BIN="${WORK_DIR}/aur-fake-bin" +AUR_REAL_GIT="$(command -v git)" mkdir -p "${AUR_FAKE_BIN}" cat > "${AUR_FAKE_BIN}/curl" <<'EOF' #!/usr/bin/env bash -echo 'AUR_TEST_NETWORK_ACCESS' >&2 -exit 97 +output="" +while [[ $# -gt 0 ]]; do + if [[ "$1" == "-o" ]]; then + output="$2" + shift 2 + else + shift + fi +done +case "${AUR_TEST_CURL_MODE:-fail}" in + empty) + : > "${output:?curl fixture did not receive -o}" + exit 0 + ;; + *) + echo 'AUR_TEST_NETWORK_ACCESS' >&2 + exit 97 + ;; +esac +EOF +cat > "${AUR_FAKE_BIN}/git" <<'EOF' +#!/usr/bin/env bash +for arg in "$@"; do + if [[ "$arg" == "push" && -n "${AUR_TEST_GIT_LOG:-}" ]]; then + printf 'push %s\n' "$*" >> "${AUR_TEST_GIT_LOG}" + exit 96 + fi +done +exec "${AUR_TEST_REAL_GIT:?}" "$@" EOF -chmod +x "${AUR_FAKE_BIN}/curl" +chmod +x "${AUR_FAKE_BIN}/curl" "${AUR_FAKE_BIN}/git" + +commit_aur_fixture() { + local dir="$1" + git -C "${dir}" -c user.name='Wayscriber Test' \ + -c user.email='wayscriber-test@example.invalid' \ + commit -qm 'test fixture' +} + +assert_clean_checkout_at_head() { + local dir="$1" expected_head="$2" context="$3" + [[ "$(git -C "${dir}" rev-parse HEAD)" == "${expected_head}" ]] || { + echo "${context}: checkout HEAD changed" >&2 + exit 1 + } + [[ -z "$(git -C "${dir}" status --porcelain)" ]] || { + echo "${context}: checkout worktree changed" >&2 + git -C "${dir}" status --short >&2 + exit 1 + } +} write_source_clone() { local dir="$1" pkgver="$2" pkgrel="$3" @@ -851,6 +901,7 @@ EOF touch "${dir}/wayscriber.install" git -C "${dir}" init -q git -C "${dir}" add PKGBUILD .SRCINFO wayscriber.install + commit_aur_fixture "${dir}" } write_configurator_clone() { @@ -860,6 +911,7 @@ write_configurator_clone() { pkgname=wayscriber-configurator pkgver=${pkgver} pkgrel=${pkgrel} +pkgdesc='GUI configurator for wayscriber (Iced)' depends=( 'gcc-libs' ) @@ -874,6 +926,7 @@ build() { EOF cat > "${dir}/.SRCINFO" <"${output}" 2>&1 - local status=$? - set -e - if [[ ${status} -eq 0 ]]; then + if run_aur_updater "${cwd}" "$@" >"${output}" 2>&1; then echo "Expected AUR updater failure containing: ${expected}" >&2 exit 1 fi assert_contains "${output}" "${expected}" } +expect_invalid_aur_version() { + local expected="$1" manifest="$2" + shift 2 + local push_log="${WORK_DIR}/aur-invalid-version-push-log" + + AUR_TEST_USE_MANIFEST_VERSION=1 \ + AUR_TEST_GIT_LOG="${push_log}" \ + expect_aur_failure "${expected}" "${WORK_DIR}" \ + --manifest "${manifest}" \ + --source-dir aur-invalid-version-source \ + --bin-dir missing-bin \ + --config-dir missing-config \ + --no-configurator \ + --source-sha256 "${AUR_SOURCE_SHA}" \ + --push \ + "$@" + assert_clean_checkout_at_head \ + "${AUR_INVALID_VERSION_SOURCE}" \ + "${AUR_INVALID_VERSION_HEAD}" \ + "invalid release version" + [[ ! -s "${push_log}" ]] || { + echo "AUR updater pushed for an invalid release version" >&2 + exit 1 + } +} + +# Release identity is validated before checkout discovery or mutation. A JSON +# string is required when the manifest supplies it, and both input routes use +# the same MAJOR.MINOR.PATCH[.HOTFIX] grammar as the rest of the release tools. +AUR_INVALID_VERSION_SOURCE="${WORK_DIR}/aur-invalid-version-source" +AUR_VERSION_MISSING_MANIFEST="${WORK_DIR}/manifest-version-missing.json" +AUR_VERSION_NULL_MANIFEST="${WORK_DIR}/manifest-version-null.json" +AUR_VERSION_ARRAY_MANIFEST="${WORK_DIR}/manifest-version-array.json" +AUR_VERSION_OBJECT_MANIFEST="${WORK_DIR}/manifest-version-object.json" +AUR_VERSION_MALFORMED_MANIFEST="${WORK_DIR}/manifest-version-malformed.json" +write_source_clone "${AUR_INVALID_VERSION_SOURCE}" 9.9.8 2 +AUR_INVALID_VERSION_HEAD="$(git -C "${AUR_INVALID_VERSION_SOURCE}" rev-parse HEAD)" +cat > "${AUR_VERSION_MISSING_MANIFEST}" <<'EOF' +{"artifacts":[]} +EOF +cat > "${AUR_VERSION_NULL_MANIFEST}" <<'EOF' +{"version":null,"artifacts":[]} +EOF +cat > "${AUR_VERSION_ARRAY_MANIFEST}" <<'EOF' +{"version":[9,9,9],"artifacts":[]} +EOF +cat > "${AUR_VERSION_OBJECT_MANIFEST}" <<'EOF' +{"version":{"major":9,"minor":9,"patch":9},"artifacts":[]} +EOF +cat > "${AUR_VERSION_MALFORMED_MANIFEST}" <<'EOF' +{"version":"9.9","artifacts":[]} +EOF +expect_invalid_aur_version "Manifest version must be a string" \ + "${AUR_VERSION_MISSING_MANIFEST}" +expect_invalid_aur_version "Manifest version must be a string" \ + "${AUR_VERSION_NULL_MANIFEST}" +expect_invalid_aur_version "Manifest version must be a string" \ + "${AUR_VERSION_ARRAY_MANIFEST}" +expect_invalid_aur_version "Manifest version must be a string" \ + "${AUR_VERSION_OBJECT_MANIFEST}" +expect_invalid_aur_version "Invalid manifest version '9.9'" \ + "${AUR_VERSION_MALFORMED_MANIFEST}" +expect_invalid_aur_version "Invalid --version 'release-9.9.9'" \ + "${MANIFEST}" \ + --version release-9.9.9 + # Relative clone paths used to be resolved after pushd, turning `dir/PKGBUILD` # into `dir/dir/PKGBUILD` and resetting same-version hotfixes to pkgrel=1. AUR_SOURCE_HOTFIX="${WORK_DIR}/aur-source-hotfix" @@ -938,6 +1061,16 @@ run_aur_updater "${WORK_DIR}" \ --source-sha256 "${AUR_SOURCE_SHA}" >/dev/null assert_contains "${AUR_CONFIG_HOTFIX}/PKGBUILD" "pkgrel=4" assert_contains "${AUR_CONFIG_HOTFIX}/.SRCINFO" "pkgrel = 4" +assert_contains "${AUR_CONFIG_HOTFIX}/PKGBUILD" \ + "pkgdesc='GUI configurator for wayscriber (GTK4/libadwaita)'" +assert_contains "${AUR_CONFIG_HOTFIX}/PKGBUILD" "'gtk4'" +assert_contains "${AUR_CONFIG_HOTFIX}/PKGBUILD" "'libadwaita>=1.4'" +assert_not_contains "${AUR_CONFIG_HOTFIX}/PKGBUILD" "(Iced)" +assert_contains "${AUR_CONFIG_HOTFIX}/.SRCINFO" \ + "pkgdesc = GUI configurator for wayscriber (GTK4/libadwaita)" +assert_contains "${AUR_CONFIG_HOTFIX}/.SRCINFO" "depends = gtk4" +assert_contains "${AUR_CONFIG_HOTFIX}/.SRCINFO" "depends = libadwaita>=1.4" +assert_not_contains "${AUR_CONFIG_HOTFIX}/.SRCINFO" "(Iced)" # A missing required configurator clone aborts before an earlier source channel # is touched. Skipping it remains available, but only as an explicit decision. @@ -951,6 +1084,16 @@ expect_aur_failure "wayscriber-configurator AUR clone not found" "${WORK_DIR}" \ --source-sha256 "${AUR_SOURCE_SHA}" cmp "${WORK_DIR}/aur-preflight-source.before" "${AUR_PREFLIGHT_SOURCE}/PKGBUILD" +AUR_NOT_GIT="${WORK_DIR}/aur-not-git" +mkdir -p "${AUR_NOT_GIT}" +touch "${AUR_NOT_GIT}/PKGBUILD" "${AUR_NOT_GIT}/.SRCINFO" +expect_aur_failure "wayscriber AUR path is not a Git worktree" "${WORK_DIR}" \ + --source-dir aur-not-git \ + --bin-dir missing-bin \ + --config-dir missing-config \ + --no-configurator \ + --source-sha256 "${AUR_SOURCE_SHA}" + AUR_SKIP_OUTPUT="${WORK_DIR}/aur-skip-output" run_aur_updater "${WORK_DIR}" \ --source-dir missing-source \ @@ -971,6 +1114,14 @@ expect_aur_failure "Failed to download the source archive" "${WORK_DIR}" \ --no-configurator cmp "${WORK_DIR}/aur-checksum-source.before" "${AUR_CHECKSUM_SOURCE}/PKGBUILD" +AUR_TEST_CURL_MODE=empty expect_aur_failure \ + "Downloaded an empty source archive" "${WORK_DIR}" \ + --source-dir aur-checksum-source \ + --bin-dir missing-bin \ + --config-dir missing-config \ + --no-configurator +cmp "${WORK_DIR}/aur-checksum-source.before" "${AUR_CHECKSUM_SOURCE}/PKGBUILD" + expect_aur_failure "Source archive checksum is not a sha256 digest" "${WORK_DIR}" \ --source-dir aur-checksum-source \ --bin-dir missing-bin \ @@ -978,4 +1129,84 @@ expect_aur_failure "Source archive checksum is not a sha256 digest" "${WORK_DIR} --no-configurator \ --source-sha256 invalid +# Binary artifact lookup is a cardinality-and-shape contract, not merely a +# nonempty string check. All failures happen before the bin checkout mutates. +AUR_BIN_MISSING_MANIFEST="${WORK_DIR}/manifest-bin-missing.json" +AUR_BIN_DUPLICATE_MANIFEST="${WORK_DIR}/manifest-bin-duplicate.json" +AUR_BIN_DUPLICATE_NULL_MANIFEST="${WORK_DIR}/manifest-bin-duplicate-null.json" +AUR_BIN_MALFORMED_MANIFEST="${WORK_DIR}/manifest-bin-malformed.json" +cp "${AUR_BIN_DIR}/PKGBUILD" "${WORK_DIR}/aur-bin-checksum.before" +cat > "${AUR_BIN_MISSING_MANIFEST}" <<'EOF' +{"version":"9.9.9","artifacts":[]} +EOF +cat > "${AUR_BIN_DUPLICATE_MANIFEST}" < "${AUR_BIN_DUPLICATE_NULL_MANIFEST}" < "${AUR_BIN_MALFORMED_MANIFEST}" <<'EOF' +{"version":"9.9.9","artifacts":[ + {"name":"wayscriber-v9.9.9-linux-x86_64.tar.gz","sha256":"not-a-digest"} +]} +EOF +expect_aur_failure "found 0" "${WORK_DIR}" \ + --manifest "${AUR_BIN_MISSING_MANIFEST}" \ + --source-dir missing-source \ + --bin-dir "${AUR_BIN_DIR}" \ + --config-dir missing-config \ + --no-configurator +expect_aur_failure "found 2" "${WORK_DIR}" \ + --manifest "${AUR_BIN_DUPLICATE_MANIFEST}" \ + --source-dir missing-source \ + --bin-dir "${AUR_BIN_DIR}" \ + --config-dir missing-config \ + --no-configurator +expect_aur_failure "found 2" "${WORK_DIR}" \ + --manifest "${AUR_BIN_DUPLICATE_NULL_MANIFEST}" \ + --source-dir missing-source \ + --bin-dir "${AUR_BIN_DIR}" \ + --config-dir missing-config \ + --no-configurator +expect_aur_failure "not a 64-character hexadecimal digest" "${WORK_DIR}" \ + --manifest "${AUR_BIN_MALFORMED_MANIFEST}" \ + --source-dir missing-source \ + --bin-dir "${AUR_BIN_DIR}" \ + --config-dir missing-config \ + --no-configurator +cmp "${WORK_DIR}/aur-bin-checksum.before" "${AUR_BIN_DIR}/PKGBUILD" + +# A deterministic failure in the last selected checkout must happen before +# any earlier checkout is committed or pushed. This configurator fixture lacks +# the dependency anchor needed by its GTK migration. +AUR_LATE_SOURCE="${WORK_DIR}/aur-late-source" +AUR_LATE_CONFIG="${WORK_DIR}/aur-late-config" +AUR_PUSH_LOG="${WORK_DIR}/aur-push-log" +write_source_clone "${AUR_LATE_SOURCE}" 9.9.8 1 +write_configurator_clone "${AUR_LATE_CONFIG}" 9.9.8 1 +AUR_LATE_SOURCE_HEAD="$(git -C "${AUR_LATE_SOURCE}" rev-parse HEAD)" +sed -i "/'gcc-libs'/d" "${AUR_LATE_CONFIG}/PKGBUILD" +sed -i '/depends = gcc-libs/d' "${AUR_LATE_CONFIG}/.SRCINFO" +AUR_TEST_GIT_LOG="${AUR_PUSH_LOG}" expect_aur_failure \ + "missing dependency anchor 'gcc-libs'" "${WORK_DIR}" \ + --source-dir aur-late-source \ + --bin-dir missing-bin \ + --config-dir aur-late-config \ + --source-sha256 "${AUR_SOURCE_SHA}" \ + --push +assert_clean_checkout_at_head \ + "${AUR_LATE_SOURCE}" \ + "${AUR_LATE_SOURCE_HEAD}" \ + "late configurator preflight failure" +[[ ! -s "${AUR_PUSH_LOG}" ]] || { + echo "AUR updater pushed before every selected checkout was prepared" >&2 + exit 1 +} + echo "Release packaging contract checks passed." diff --git a/tools/update-aur-from-manifest.sh b/tools/update-aur-from-manifest.sh index 200f0971..925eab9a 100644 --- a/tools/update-aur-from-manifest.sh +++ b/tools/update-aur-from-manifest.sh @@ -26,6 +26,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" VERSION="" +VERSION_PROVIDED=0 MANIFEST="${REPO_ROOT}/dist/manifest.json" AUR_SOURCE_DIR="${AUR_SOURCE_DIR:-${REPO_ROOT}/../aur-wayscriber}" AUR_BIN_DIR="${AUR_BIN_DIR:-${REPO_ROOT}/../aur-wayscriber-bin}" @@ -33,6 +34,17 @@ AUR_CONFIG_DIR="${AUR_CONFIG_DIR:-${REPO_ROOT}/../aur-wayscriber-configurator}" DO_PUSH=0 NO_CONFIGURATOR=0 SOURCE_ARCHIVE_SHA="${AUR_SOURCE_ARCHIVE_SHA256:-}" +BIN_ARCHIVE_SHA="" +PREPARED_PUSH_DIRS=() +RECIPE_PREFLIGHT_ROOT="" + +cleanup_recipe_preflight() { + if [[ -n "$RECIPE_PREFLIGHT_ROOT" && -d "$RECIPE_PREFLIGHT_ROOT" ]]; then + rm -rf -- "$RECIPE_PREFLIGHT_ROOT" + fi +} + +trap cleanup_recipe_preflight EXIT usage() { cat <<'EOF' @@ -57,7 +69,12 @@ EOF while [[ $# -gt 0 ]]; do case "$1" in - --version) VERSION="$2"; shift 2 ;; + --version) + [[ $# -ge 2 ]] || { echo "--version requires a value" >&2; exit 1; } + VERSION="$2" + VERSION_PROVIDED=1 + shift 2 + ;; --manifest) MANIFEST="$2"; shift 2 ;; --source-dir) AUR_SOURCE_DIR="$2"; shift 2 ;; --bin-dir) AUR_BIN_DIR="$2"; shift 2 ;; @@ -79,8 +96,28 @@ need perl [[ -f "$MANIFEST" ]] || { echo "Manifest not found: $MANIFEST" >&2; exit 1; } -if [[ -z "$VERSION" ]]; then - VERSION="$(jq -r '.version' "$MANIFEST")" +if [[ "$VERSION_PROVIDED" -eq 0 ]]; then + if ! VERSION="$(jq -er ' + if type != "object" then + empty + elif (.version | type) != "string" then + empty + else + .version + end + ' "$MANIFEST")"; then + echo "Manifest version must be a string matching MAJOR.MINOR.PATCH[.HOTFIX]: $MANIFEST" >&2 + exit 1 + fi +fi + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then + if [[ "$VERSION_PROVIDED" -eq 1 ]]; then + echo "Invalid --version '${VERSION}' (expected MAJOR.MINOR.PATCH[.HOTFIX])" >&2 + else + echo "Invalid manifest version '${VERSION}' (expected MAJOR.MINOR.PATCH[.HOTFIX])" >&2 + fi + exit 1 fi read_pkgver_from_pkgbuild() { @@ -125,9 +162,30 @@ next_pkgrel() { fi } -sha_for() { +artifact_sha_for() { local name="$1" - jq -r --arg n "$name" '.artifacts[] | select(.name==$n) | .sha256' "$MANIFEST" + local match_count + if ! match_count="$(jq -r --arg n "$name" '[.artifacts[]? | select(.name==$n)] | length' "$MANIFEST")"; then + echo "Could not read artifact checksums from manifest: $MANIFEST" >&2 + return 1 + fi + + if [[ "$match_count" != 1 ]]; then + echo "Expected exactly one checksum for artifact ${name}, found ${match_count}" >&2 + return 1 + fi + + local digest + if ! digest="$(jq -r --arg n "$name" '[.artifacts[]? | select(.name==$n)][0].sha256 // empty' "$MANIFEST")"; then + echo "Could not read artifact checksums from manifest: $MANIFEST" >&2 + return 1 + fi + if [[ ! "$digest" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "Artifact checksum for ${name} is not a 64-character hexadecimal digest: '${digest}'" >&2 + return 1 + fi + + printf '%s' "$digest" } source_archive_url() { @@ -172,6 +230,22 @@ require_recipe() { echo "${channel} AUR clone is missing PKGBUILD or .SRCINFO: $dir" >&2 return 1 } + + local dir_abs git_root + dir_abs="$(absolute_dir "$dir")" + if ! git_root="$(git -C "$dir_abs" rev-parse --show-toplevel 2>/dev/null)"; then + echo "${channel} AUR path is not a Git worktree: $dir" >&2 + return 1 + fi + git_root="$(absolute_dir "$git_root")" + if [[ "$git_root" != "$dir_abs" ]]; then + echo "${channel} AUR path is not the root of its Git worktree: $dir" >&2 + return 1 + fi + git -C "$dir_abs" status --porcelain >/dev/null || { + echo "${channel} AUR Git worktree is not usable: $dir" >&2 + return 1 + } } replace_line() { @@ -233,14 +307,21 @@ ensure_libxkbcommon_dependency() { fi } -# Insert before wl-clipboard to match the AUR recipes' dependency ordering. ensure_runtime_dependency() { - local dep="$1" + local dep="$1" anchor="${2:-wl-clipboard}" if ! grep -Eq "^[[:space:]]*'${dep}'[[:space:]]*\$" PKGBUILD; then - sed -i "/^[[:space:]]*'wl-clipboard'/i\\ '${dep}'" PKGBUILD + grep -Eq "^[[:space:]]*'${anchor}'[[:space:]]*\$" PKGBUILD || { + echo "PKGBUILD is missing dependency anchor '${anchor}' needed to add '${dep}'" >&2 + return 1 + } + sed -i "/^[[:space:]]*'${anchor}'/i\\ '${dep}'" PKGBUILD fi if ! grep -Eq "^[[:space:]]*depends = ${dep}\$" .SRCINFO; then - sed -i "/^[[:space:]]*depends = wl-clipboard/i\\\tdepends = ${dep}" .SRCINFO + grep -Eq "^[[:space:]]*depends = ${anchor}\$" .SRCINFO || { + echo ".SRCINFO is missing dependency anchor '${anchor}' needed to add '${dep}'" >&2 + return 1 + } + sed -i "/^[[:space:]]*depends = ${anchor}/i\\\tdepends = ${dep}" .SRCINFO fi } @@ -293,14 +374,88 @@ commit_metadata_changes() { git commit -m "$message" -- "${commit_paths[@]}" } +validate_recipe_pair() { + local channel="$1" dir="$2" checksum_field="$3" expected_checksum="$4" + local pkgbuild_pkgrel srcinfo_pkgver srcinfo_pkgrel srcinfo_checksum + + pushd "$dir" >/dev/null + [[ "$(read_pkgver_from_pkgbuild PKGBUILD)" == "$VERSION" ]] || { + echo "${channel} PKGBUILD did not receive pkgver=${VERSION}" >&2 + return 1 + } + pkgbuild_pkgrel="$(read_pkgrel_from_pkgbuild PKGBUILD)" + [[ "$pkgbuild_pkgrel" =~ ^[1-9][0-9]*$ ]] || { + echo "${channel} PKGBUILD has invalid pkgrel=${pkgbuild_pkgrel}" >&2 + return 1 + } + srcinfo_pkgver="$(awk -F ' = ' '/^[[:space:]]*pkgver = / {print $2}' .SRCINFO)" + srcinfo_pkgrel="$(awk -F ' = ' '/^[[:space:]]*pkgrel = / {print $2}' .SRCINFO)" + srcinfo_checksum="$(awk -F ' = ' -v field="$checksum_field" '$1 ~ "^[[:space:]]*" field "$" {print $2}' .SRCINFO)" + [[ "$srcinfo_pkgver" == "$VERSION" && "$srcinfo_pkgrel" == "$pkgbuild_pkgrel" ]] || { + echo "${channel} PKGBUILD and .SRCINFO version metadata disagree" >&2 + return 1 + } + grep -Fq "${checksum_field}=('${expected_checksum}')" PKGBUILD || { + echo "${channel} PKGBUILD did not receive the expected checksum" >&2 + return 1 + } + [[ "$srcinfo_checksum" == "$expected_checksum" ]] || { + echo "${channel} .SRCINFO did not receive the expected checksum" >&2 + return 1 + } + popd >/dev/null +} + +validate_configurator_recipe() { + local dir="$1" + validate_recipe_pair \ + "wayscriber-configurator" "$dir" sha256sums "$SOURCE_ARCHIVE_SHA" + + pushd "$dir" >/dev/null + grep -Fxq "pkgdesc='GUI configurator for wayscriber (GTK4/libadwaita)'" PKGBUILD || { + echo "wayscriber-configurator PKGBUILD has stale GUI metadata" >&2 + return 1 + } + grep -Eq "^[[:space:]]*'gtk4'[[:space:]]*$" PKGBUILD \ + && grep -Eq "^[[:space:]]*'libadwaita>=1.4'[[:space:]]*$" PKGBUILD \ + && grep -Fxq $'\tpkgdesc = GUI configurator for wayscriber (GTK4/libadwaita)' .SRCINFO \ + && grep -Fxq $'\tdepends = gtk4' .SRCINFO \ + && grep -Fxq $'\tdepends = libadwaita>=1.4' .SRCINFO || { + echo "wayscriber-configurator recipe lacks GTK4/libadwaita metadata" >&2 + return 1 + } + popd >/dev/null +} + +prepare_channel_commit() { + local dir="$1" message="$2" + shift 2 + local dir_abs + dir_abs="$(absolute_dir "$dir")" + + pushd "$dir_abs" >/dev/null + local paths=(PKGBUILD .SRCINFO "$@") + if [[ -z "$(git status --porcelain -- "${paths[@]}")" ]]; then + popd >/dev/null + return + fi + commit_metadata_changes "$message" "$@" + PREPARED_PUSH_DIRS+=("$dir_abs") + popd >/dev/null +} + +push_prepared_channels() { + local dir + for dir in "${PREPARED_PUSH_DIRS[@]}"; do + git -C "$dir" push + done +} + update_bin() { local dir="$1" local pkgrel [[ -d "$dir" ]] || { echo "Skip bin: $dir not found" >&2; return; } - local sha - sha="$(sha_for "wayscriber-v${VERSION}-linux-x86_64.tar.gz")" - [[ -n "$sha" && "$sha" != "null" ]] || { echo "Bin checksum missing in manifest" >&2; exit 1; } pkgrel="$(next_pkgrel "$dir")" pushd "$dir" >/dev/null @@ -314,18 +469,14 @@ update_bin() { replace_line PKGBUILD '^pkgver=.*' "pkgver=${VERSION}" replace_line PKGBUILD '^pkgrel=.*' "pkgrel=${pkgrel}" replace_pkgbuild_array PKGBUILD source_x86_64 "source_x86_64=(\"wayscriber-v${VERSION}-linux-x86_64.tar.gz::https://github.com/devmobasa/wayscriber/releases/download/v${VERSION}/wayscriber-v${VERSION}-linux-x86_64.tar.gz\")" - replace_pkgbuild_array PKGBUILD sha256sums_x86_64 "sha256sums_x86_64=('${sha}')" + replace_pkgbuild_array PKGBUILD sha256sums_x86_64 "sha256sums_x86_64=('${BIN_ARCHIVE_SHA}')" set_srcinfo_field .SRCINFO pkgver "${VERSION}" set_srcinfo_field .SRCINFO pkgrel "${pkgrel}" set_srcinfo_field .SRCINFO source_x86_64 "wayscriber-v${VERSION}-linux-x86_64.tar.gz::https://github.com/devmobasa/wayscriber/releases/download/v${VERSION}/wayscriber-v${VERSION}-linux-x86_64.tar.gz" - set_srcinfo_field .SRCINFO sha256sums_x86_64 "${sha}" + set_srcinfo_field .SRCINFO sha256sums_x86_64 "${BIN_ARCHIVE_SHA}" git status --short - if [[ "$DO_PUSH" -eq 1 && -n "$(git status --porcelain)" ]]; then - commit_metadata_changes "wayscriber-bin ${VERSION}" "wayscriber-bin.install" - git push - fi popd >/dev/null } @@ -358,10 +509,6 @@ update_source() { set_srcinfo_field .SRCINFO sha256sums "${source_sha}" git status --short - if [[ "$DO_PUSH" -eq 1 && -n "$(git status --porcelain)" ]]; then - commit_metadata_changes "wayscriber ${VERSION}" "wayscriber.install" - git push - fi popd >/dev/null } @@ -378,8 +525,11 @@ update_configurator() { remove_pkgbuild_array_item PKGBUILD git remove_srcinfo_field_value .SRCINFO makedepends git ensure_libxkbcommon_dependency + ensure_runtime_dependency gtk4 gcc-libs + ensure_runtime_dependency 'libadwaita>=1.4' gcc-libs replace_line PKGBUILD '^pkgver=.*' "pkgver=${VERSION}" replace_line PKGBUILD '^pkgrel=.*' "pkgrel=${pkgrel}" + replace_line PKGBUILD '^pkgdesc=.*' "pkgdesc='GUI configurator for wayscriber (GTK4/libadwaita)'" replace_pkgbuild_array PKGBUILD source 'source=("wayscriber-$pkgver.tar.gz::https://github.com/devmobasa/wayscriber/archive/refs/tags/v$pkgver.tar.gz")' replace_pkgbuild_array PKGBUILD sha256sums "sha256sums=('${source_sha}')" replace_line PKGBUILD '^ cd wayscriber$' ' cd "wayscriber-$pkgver"' @@ -387,44 +537,121 @@ update_configurator() { set_srcinfo_field .SRCINFO pkgver "${VERSION}" set_srcinfo_field .SRCINFO pkgrel "${pkgrel}" + set_srcinfo_field .SRCINFO pkgdesc "GUI configurator for wayscriber (GTK4/libadwaita)" set_srcinfo_field .SRCINFO source "wayscriber-${VERSION}.tar.gz::${source_url}" set_srcinfo_field .SRCINFO sha256sums "${source_sha}" git status --short - if [[ "$DO_PUSH" -eq 1 && -n "$(git status --porcelain)" ]]; then - commit_metadata_changes "wayscriber-configurator ${VERSION}" - git push - fi popd >/dev/null } +copy_recipe_for_preflight() { + local source_dir="$1" destination="$2" + mkdir -p "$destination" + cp -a -- "$source_dir/." "$destination/" + if [[ -e "$destination/.git" || -L "$destination/.git" ]]; then + rm -rf -- "$destination/.git" + fi + git -C "$destination" init -q + git -C "$destination" add -A +} + +transform_selected_recipes() { + local source_dir="$1" bin_dir="$2" config_dir="$3" + if [[ "$SOURCE_SELECTED" -eq 1 ]]; then + update_source "$source_dir" + fi + if [[ "$BIN_SELECTED" -eq 1 ]]; then + update_bin "$bin_dir" + fi + if [[ "$CONFIGURATOR_SELECTED" -eq 1 ]]; then + update_configurator "$config_dir" + fi +} + +validate_selected_recipes() { + local source_dir="$1" bin_dir="$2" config_dir="$3" + if [[ "$SOURCE_SELECTED" -eq 1 ]]; then + validate_recipe_pair "wayscriber" "$source_dir" sha256sums "$SOURCE_ARCHIVE_SHA" + fi + if [[ "$BIN_SELECTED" -eq 1 ]]; then + validate_recipe_pair \ + "wayscriber-bin" "$bin_dir" sha256sums_x86_64 "$BIN_ARCHIVE_SHA" + fi + if [[ "$CONFIGURATOR_SELECTED" -eq 1 ]]; then + validate_configurator_recipe "$config_dir" + fi +} + +preflight_recipe_transformations() { + RECIPE_PREFLIGHT_ROOT="$(mktemp -d)" + local source_preview="${RECIPE_PREFLIGHT_ROOT}/source" + local bin_preview="${RECIPE_PREFLIGHT_ROOT}/bin" + local config_preview="${RECIPE_PREFLIGHT_ROOT}/configurator" + + if [[ "$SOURCE_SELECTED" -eq 1 ]]; then + copy_recipe_for_preflight "$AUR_SOURCE_DIR" "$source_preview" + fi + if [[ "$BIN_SELECTED" -eq 1 ]]; then + copy_recipe_for_preflight "$AUR_BIN_DIR" "$bin_preview" + fi + if [[ "$CONFIGURATOR_SELECTED" -eq 1 ]]; then + copy_recipe_for_preflight "$AUR_CONFIG_DIR" "$config_preview" + fi + + transform_selected_recipes "$source_preview" "$bin_preview" "$config_preview" + validate_selected_recipes "$source_preview" "$bin_preview" "$config_preview" + cleanup_recipe_preflight + RECIPE_PREFLIGHT_ROOT="" +} + echo "Updating AUR using manifest: ${MANIFEST}" echo "Version: ${VERSION}" # The configurator is a release channel, not a best-effort extra. Check all # known late failures before any earlier channel can commit and push. +SOURCE_SELECTED=0 +BIN_SELECTED=0 +CONFIGURATOR_SELECTED=0 if [[ "$NO_CONFIGURATOR" -eq 0 ]]; then require_recipe "wayscriber-configurator" "$AUR_CONFIG_DIR" + CONFIGURATOR_SELECTED=1 fi if [[ -d "$AUR_SOURCE_DIR" ]]; then require_recipe "wayscriber" "$AUR_SOURCE_DIR" + SOURCE_SELECTED=1 fi if [[ -d "$AUR_BIN_DIR" ]]; then require_recipe "wayscriber-bin" "$AUR_BIN_DIR" - bin_sha="$(sha_for "wayscriber-v${VERSION}-linux-x86_64.tar.gz")" - [[ -n "$bin_sha" && "$bin_sha" != "null" ]] || { - echo "Bin checksum missing in manifest" >&2 - exit 1 - } + BIN_SELECTED=1 + BIN_ARCHIVE_SHA="$(artifact_sha_for "wayscriber-v${VERSION}-linux-x86_64.tar.gz")" fi if [[ -d "$AUR_SOURCE_DIR" || ( "$NO_CONFIGURATOR" -eq 0 && -d "$AUR_CONFIG_DIR" ) ]]; then SOURCE_ARCHIVE_SHA="$(source_archive_sha)" fi -update_source "${AUR_SOURCE_DIR}" -update_bin "${AUR_BIN_DIR}" if [[ "$NO_CONFIGURATOR" -eq 1 ]]; then echo "Skipping wayscriber-configurator: --no-configurator was passed." >&2 -else - update_configurator "${AUR_CONFIG_DIR}" +fi + +# Prove every deterministic transformation and validation against isolated +# copies before touching a selected checkout. Then repeat the proven route on +# the real worktrees before the commit/push boundary. Separate AUR repositories +# still cannot be pushed atomically; a later remote/network failure can split +# publication after this point. +preflight_recipe_transformations +transform_selected_recipes "$AUR_SOURCE_DIR" "$AUR_BIN_DIR" "$AUR_CONFIG_DIR" +validate_selected_recipes "$AUR_SOURCE_DIR" "$AUR_BIN_DIR" "$AUR_CONFIG_DIR" + +if [[ "$DO_PUSH" -eq 1 ]]; then + if [[ "$SOURCE_SELECTED" -eq 1 ]]; then + prepare_channel_commit "$AUR_SOURCE_DIR" "wayscriber ${VERSION}" wayscriber.install + fi + if [[ "$BIN_SELECTED" -eq 1 ]]; then + prepare_channel_commit "$AUR_BIN_DIR" "wayscriber-bin ${VERSION}" wayscriber-bin.install + fi + if [[ "$CONFIGURATOR_SELECTED" -eq 1 ]]; then + prepare_channel_commit "$AUR_CONFIG_DIR" "wayscriber-configurator ${VERSION}" + fi + push_prepared_channels fi