From 65bae90bc8bc1ba1c5f5a32226f7db9d87bd0cb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:02:51 +0000 Subject: [PATCH 1/4] Launch multi-command workspaces as tabs in one terminal window Workspaces with multiple enabled launch entries used to open one Windows Terminal window per entry. Now entries that resolve to the same Windows Terminal host and elevation requirement are combined into a single wt.exe/wtai.exe invocation using `new-tab`, so a workspace with several commands opens as tabs in one window instead of separate windows. Entries needing different elevation, or that fall back to a non-tab-capable host (standalone PowerShell/cmd/wsl, only used when Windows Terminal isn't installed), still launch as separate windows. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QA3QpUk1crPby6PYjnZJ2Y --- .../ShortcutLaunchExecutorTests.cs | 102 ++++++++++++++++++ .../Services/ShortcutLaunchExecutor.cs | 86 ++++++++++++--- QuickShell.Core/Services/TerminalLauncher.cs | 98 +++++++++++++---- 3 files changed, 250 insertions(+), 36 deletions(-) diff --git a/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs b/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs index 4388b9a..830710e 100644 --- a/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs +++ b/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using QuickShell.Models; using QuickShell.Services; @@ -5,6 +6,107 @@ namespace QuickShell.Core.Tests; public sealed class ShortcutLaunchExecutorTests { + [Fact] + public void LaunchAll_ThreeWindowsTerminalEntries_OpenAsSingleProcessWithTabs() + { + var directory = Environment.CurrentDirectory; + var shortcut = new TerminalShortcut + { + Id = Guid.NewGuid().ToString("N"), + Name = "Full stack", + Directory = directory, + Launches = + [ + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "API", Terminal = "wt", WtProfile = "Profile1", Command = "dotnet run", IsEnabled = true, Order = 0 }, + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Web", Terminal = "wt", WtProfile = "Profile2", Command = "npm run dev", IsEnabled = true, Order = 1 }, + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Worker", Terminal = "wt", WtProfile = "Profile3", Command = "npm run worker", IsEnabled = true, Order = 2 }, + ], + }; + + var captured = new List(); + TerminalLauncher.StartProcessOverride = info => { captured.Add(info); return true; }; + try + { + ShortcutLaunchExecutor.Launch(shortcut, "wt", "default"); + + Assert.Single(captured); + Assert.Equal("wt.exe", captured[0].FileName); + Assert.Equal(2, CountOccurrences(captured[0].Arguments ?? string.Empty, "; new-tab")); + } + finally + { + TerminalLauncher.StartProcessOverride = null; + } + } + + [Fact] + public void LaunchAll_MixedElevation_OpensTwoProcesses() + { + var directory = Environment.CurrentDirectory; + var shortcut = new TerminalShortcut + { + Id = Guid.NewGuid().ToString("N"), + Name = "Mixed elevation", + Directory = directory, + Launches = + [ + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Admin", Terminal = "wt", WtProfile = "Profile1", Command = "cmd", RunAsAdmin = true, IsEnabled = true, Order = 0 }, + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Normal", Terminal = "wt", WtProfile = "Profile2", Command = "cmd", RunAsAdmin = false, IsEnabled = true, Order = 1 }, + ], + }; + + var captured = new List(); + TerminalLauncher.StartProcessOverride = info => { captured.Add(info); return true; }; + try + { + ShortcutLaunchExecutor.Launch(shortcut, "wt", "default"); + + Assert.Equal(2, captured.Count); + Assert.Contains(captured, c => c.Verb == "runas"); + Assert.Contains(captured, c => string.IsNullOrEmpty(c.Verb)); + } + finally + { + TerminalLauncher.StartProcessOverride = null; + } + } + + [Fact] + public void LaunchAll_NonWindowsTerminalFallbackMixedWithWt_OpensTwoProcesses() + { + var directory = Environment.CurrentDirectory; + var shortcut = new TerminalShortcut + { + Id = Guid.NewGuid().ToString("N"), + Name = "Mixed hosts", + Directory = directory, + Launches = + [ + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "API", Terminal = "wt", WtProfile = "Profile1", Command = "cmd", IsEnabled = true, Order = 0 }, + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Web", Terminal = "wt", WtProfile = "Profile2", Command = "cmd", IsEnabled = true, Order = 1 }, + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Legacy", Terminal = "cmd", Command = "cmd", IsEnabled = true, Order = 2 }, + ], + }; + + var captured = new List(); + TerminalLauncher.StartProcessOverride = info => { captured.Add(info); return true; }; + try + { + ShortcutLaunchExecutor.Launch(shortcut, "wt", "default"); + + Assert.Equal(2, captured.Count); + Assert.Contains(captured, c => c.FileName == "wt.exe" && (c.Arguments ?? string.Empty).Contains("; new-tab", StringComparison.Ordinal)); + Assert.Contains(captured, c => c.FileName == "cmd.exe"); + } + finally + { + TerminalLauncher.StartProcessOverride = null; + } + } + + private static int CountOccurrences(string haystack, string needle) => + needle.Length == 0 ? 0 : (haystack.Length - haystack.Replace(needle, string.Empty).Length) / needle.Length; + [Fact] public void Launch_ReturnsErrorWhenDirectoryMissing() { diff --git a/QuickShell.Core/Services/ShortcutLaunchExecutor.cs b/QuickShell.Core/Services/ShortcutLaunchExecutor.cs index 7df8d52..0785ac2 100644 --- a/QuickShell.Core/Services/ShortcutLaunchExecutor.cs +++ b/QuickShell.Core/Services/ShortcutLaunchExecutor.cs @@ -149,6 +149,11 @@ private static ShortcutLaunchResult LaunchSingle( } } + private readonly record struct EntryPlan( + WorkspaceEntry Entry, + ResolvedLaunch Resolved, + bool EffectiveElevation); + private static ShortcutLaunchResult LaunchAll( TerminalShortcut shortcut, IReadOnlyList enabledLaunches, @@ -159,7 +164,7 @@ private static ShortcutLaunchResult LaunchAll( bool companionSucceeded, string? companionError) { - var opened = 0; + var plans = new List(); string? lastFailureLabel = null; foreach (var launch in enabledLaunches) @@ -167,13 +172,9 @@ private static ShortcutLaunchResult LaunchAll( try { var launchShortcut = ShortcutLaunchNormalization.ToLaunchShortcut(launch, shortcut); - TerminalLauncher.Open( - launchShortcut, - terminalApplicationId, - defaultProfileId, - options.RunAsAdmin, - options.RunAsStandard); - opened++; + var resolved = TerminalLauncher.Resolve(launchShortcut, terminalApplicationId, defaultProfileId); + var effectiveElevation = !options.RunAsStandard && (options.RunAsAdmin || launch.RunAsAdmin); + plans.Add(new EntryPlan(launch, resolved, effectiveElevation)); } catch (DirectoryNotFoundException) { @@ -183,23 +184,49 @@ private static ShortcutLaunchResult LaunchAll( { lastFailureLabel = launch.Label; } + } + + var groups = GroupPlans(plans); + var openedCommands = 0; + + foreach (var group in groups) + { + try + { + if (group.Count == 1) + { + TerminalLauncher.OpenResolved(group[0].Resolved, group[0].EffectiveElevation); + } + else + { + TerminalLauncher.OpenGroup( + group.Select(p => p.Resolved).ToList(), + group[0].EffectiveElevation); + } + + openedCommands += group.Count; + } catch (Win32Exception) { - lastFailureLabel = launch.Label; + lastFailureLabel = group[^1].Entry.Label; + } + catch (InvalidOperationException) + { + lastFailureLabel = group[^1].Entry.Label; } } - if (opened == 0) + if (openedCommands == 0) { return ShortcutLaunchResult.StayOpen( lastFailureLabel is null - ? "Workspace could not launch any terminals." + ? "Workspace could not launch any commands." : $"{lastFailureLabel} could not be launched."); } - var successPrefix = opened == enabledLaunches.Count + var successPrefix = openedCommands == enabledLaunches.Count ? "Workspace launched" - : $"Workspace partially launched: {opened} of {enabledLaunches.Count} terminals opened"; + : $"Workspace partially launched: {openedCommands} of {enabledLaunches.Count} commands launched"; return BuildPostLaunchResult( shortcut, @@ -208,7 +235,38 @@ lastFailureLabel is null companionSucceeded, companionError, successPrefix, - partialLaunch: opened < enabledLaunches.Count); + partialLaunch: openedCommands < enabledLaunches.Count); + } + + private static List> GroupPlans(List plans) + { + var groups = new List>(); + var groupIndexByKey = new Dictionary<(string Host, bool Elevated), int>(); + + foreach (var plan in plans) + { + var isTabCapable = plan.Resolved.Target.Kind is + LaunchTargetKind.WindowsTerminal or LaunchTargetKind.IntelligentTerminal; + + if (!isTabCapable) + { + groups.Add([plan]); + continue; + } + + var key = (plan.Resolved.Target.HostExecutable, plan.EffectiveElevation); + if (groupIndexByKey.TryGetValue(key, out var index)) + { + groups[index].Add(plan); + } + else + { + groupIndexByKey[key] = groups.Count; + groups.Add([plan]); + } + } + + return groups; } private static ShortcutLaunchResult BuildPostLaunchResult( diff --git a/QuickShell.Core/Services/TerminalLauncher.cs b/QuickShell.Core/Services/TerminalLauncher.cs index e39b55b..6e3fdc7 100644 --- a/QuickShell.Core/Services/TerminalLauncher.cs +++ b/QuickShell.Core/Services/TerminalLauncher.cs @@ -3,16 +3,16 @@ namespace QuickShell.Services; +internal readonly record struct ResolvedLaunch(TerminalShortcut Shortcut, LaunchTarget Target); + internal static class TerminalLauncher { internal static Func? StartProcessOverride { get; set; } - public static void Open( + public static ResolvedLaunch Resolve( TerminalShortcut shortcut, string terminalApplicationId, - string defaultProfileId, - bool runAsAdmin = false, - bool runAsStandard = false) + string defaultProfileId) { if (!ShortcutValidation.TryNormalizeDirectory(shortcut.Directory, out var directory, out var error)) { @@ -44,22 +44,76 @@ public static void Open( }; var target = TerminalCatalog.ResolveForShortcut(launchShortcut, terminalApplicationId, defaultProfileId); - var startInfo = target.Kind switch - { - LaunchTargetKind.WindowsTerminal or LaunchTargetKind.IntelligentTerminal => - CreateWindowsTerminalStartInfo(launchShortcut, target), - LaunchTargetKind.PowerShell => CreatePowerShellStartInfo(launchShortcut, usePwsh: false), - LaunchTargetKind.Pwsh => CreatePowerShellStartInfo(launchShortcut, usePwsh: true), - LaunchTargetKind.Cmd => CreateCmdStartInfo(launchShortcut, target), - LaunchTargetKind.Wsl => CreateWslStartInfo(launchShortcut, target), - _ => CreateWindowsTerminalStartInfo(launchShortcut, target), - }; + return new ResolvedLaunch(launchShortcut, target); + } + + public static void Open( + TerminalShortcut shortcut, + string terminalApplicationId, + string defaultProfileId, + bool runAsAdmin = false, + bool runAsStandard = false) + { + var resolved = Resolve(shortcut, terminalApplicationId, defaultProfileId); + var effectiveElevation = !runAsStandard && (runAsAdmin || shortcut.RunAsAdmin); + OpenResolved(resolved, effectiveElevation); + } + + public static void OpenResolved(ResolvedLaunch resolved, bool effectiveElevation) + { + var startInfo = BuildStartInfo(resolved.Shortcut, resolved.Target); - if (!runAsStandard && (runAsAdmin || shortcut.RunAsAdmin)) + if (effectiveElevation) { startInfo.Verb = "runas"; } + StartProcess(startInfo); + } + + /// + /// Launches multiple Windows Terminal-hosted entries as tabs of a single window/process. + /// Every entry must resolve to the same ; elevation + /// applies to the whole window, so callers must group entries by matching elevation first. + /// + public static void OpenGroup(IReadOnlyList group, bool effectiveElevation) + { + var hostExecutable = group[0].Target.HostExecutable; + var allArguments = new List(); + + for (var i = 0; i < group.Count; i++) + { + if (i > 0) + { + allArguments.Add(";"); + allArguments.Add("new-tab"); + } + + allArguments.AddRange(BuildWindowsTerminalArguments(group[i].Shortcut, group[i].Target)); + } + + var startInfo = CreateWtStartInfo(allArguments, hostExecutable); + if (effectiveElevation) + { + startInfo.Verb = "runas"; + } + + StartProcess(startInfo); + } + + private static ProcessStartInfo BuildStartInfo(TerminalShortcut shortcut, LaunchTarget target) => target.Kind switch + { + LaunchTargetKind.WindowsTerminal or LaunchTargetKind.IntelligentTerminal => + CreateWtStartInfo(BuildWindowsTerminalArguments(shortcut, target), target.HostExecutable), + LaunchTargetKind.PowerShell => CreatePowerShellStartInfo(shortcut, usePwsh: false), + LaunchTargetKind.Pwsh => CreatePowerShellStartInfo(shortcut, usePwsh: true), + LaunchTargetKind.Cmd => CreateCmdStartInfo(shortcut, target), + LaunchTargetKind.Wsl => CreateWslStartInfo(shortcut, target), + _ => CreateWtStartInfo(BuildWindowsTerminalArguments(shortcut, target), target.HostExecutable), + }; + + private static void StartProcess(ProcessStartInfo startInfo) + { if (StartProcessOverride is { } startOverride) { if (!startOverride(startInfo)) @@ -73,11 +127,11 @@ public static void Open( } } - private static ProcessStartInfo CreateWindowsTerminalStartInfo(TerminalShortcut shortcut, LaunchTarget target) + private static List BuildWindowsTerminalArguments(TerminalShortcut shortcut, LaunchTarget target) { if (WslPathResolver.TryParse(shortcut.Directory, out var wslLocation)) { - return CreateWindowsTerminalForWslDirectory(shortcut, target, wslLocation); + return BuildWindowsTerminalArgumentsForWslDirectory(shortcut, target, wslLocation); } var arguments = new List(); @@ -99,10 +153,10 @@ private static ProcessStartInfo CreateWindowsTerminalStartInfo(TerminalShortcut arguments.Add(BuildWindowsTerminalCommandSuffix(shortcut, target, omitDirectoryChange)); } - return CreateWtStartInfo(arguments, target.HostExecutable); + return arguments; } - private static ProcessStartInfo CreateWindowsTerminalForWslDirectory( + private static List BuildWindowsTerminalArgumentsForWslDirectory( TerminalShortcut shortcut, LaunchTarget target, WslPathResolver.WslLocation wslLocation) @@ -117,18 +171,18 @@ private static ProcessStartInfo CreateWindowsTerminalForWslDirectory( if (IsWslProfile(target)) { arguments.Add(TerminalLauncherArgs.ToWslExecutableCommand(shortcut, target, wslLocation)); - return CreateWtStartInfo(arguments, target.HostExecutable); + return arguments; } if (IsPowerShellProfile(target)) { var directory = wslLocation.UncPath ?? shortcut.Directory; arguments.Add(TerminalLauncherArgs.ToPowerShellExecutableCommand(shortcut, GetPowerShellPathForProfile(target), directory)); - return CreateWtStartInfo(arguments, target.HostExecutable); + return arguments; } arguments.Add(ToWslExecutableCommand(shortcut, target, wslLocation)); - return CreateWtStartInfo(arguments, target.HostExecutable); + return arguments; } private static string BuildWindowsTerminalCommandSuffix( From eb99bc16dff095559163a7a4bfdf7af3d301cbc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:09:42 +0000 Subject: [PATCH 2/4] Address review feedback: validate OpenGroup input, fix test race OpenGroup now fails fast with a clear ArgumentException if called with an empty group or entries that aren't all Windows Terminal-hosted with a matching host executable, instead of silently misbehaving. Group the two test classes that mutate the shared static TerminalLauncher.StartProcessOverride hook into one xUnit collection so they can't run concurrently and race on that shared state. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QA3QpUk1crPby6PYjnZJ2Y --- .../ShortcutLaunchExecutorTests.cs | 9 +++++++++ QuickShell.Core/Services/TerminalLauncher.cs | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs b/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs index 830710e..c5681ac 100644 --- a/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs +++ b/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs @@ -4,6 +4,14 @@ namespace QuickShell.Core.Tests; +/// +/// Serializes tests that mutate the shared static +/// hook so they can't race with each other under xUnit's default cross-class parallelization. +/// +[CollectionDefinition("TerminalLauncher StartProcessOverride", DisableParallelization = true)] +public sealed class TerminalLauncherOverrideCollection; + +[Collection("TerminalLauncher StartProcessOverride")] public sealed class ShortcutLaunchExecutorTests { [Fact] @@ -162,6 +170,7 @@ public void Launch_ReturnsErrorWhenNoEnabledLaunches() } } +[Collection("TerminalLauncher StartProcessOverride")] public sealed class WorkspaceDevServerActionsTests { [Fact] diff --git a/QuickShell.Core/Services/TerminalLauncher.cs b/QuickShell.Core/Services/TerminalLauncher.cs index 6e3fdc7..ee454a3 100644 --- a/QuickShell.Core/Services/TerminalLauncher.cs +++ b/QuickShell.Core/Services/TerminalLauncher.cs @@ -78,18 +78,32 @@ public static void OpenResolved(ResolvedLaunch resolved, bool effectiveElevation /// public static void OpenGroup(IReadOnlyList group, bool effectiveElevation) { + if (group is not { Count: > 0 }) + { + throw new ArgumentException("Group must contain at least one resolved launch.", nameof(group)); + } + var hostExecutable = group[0].Target.HostExecutable; var allArguments = new List(); for (var i = 0; i < group.Count; i++) { + var target = group[i].Target; + if (target.Kind is not (LaunchTargetKind.WindowsTerminal or LaunchTargetKind.IntelligentTerminal) + || !target.HostExecutable.Equals(hostExecutable, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + "All entries in a group must be Windows Terminal-hosted and share the same host executable.", + nameof(group)); + } + if (i > 0) { allArguments.Add(";"); allArguments.Add("new-tab"); } - allArguments.AddRange(BuildWindowsTerminalArguments(group[i].Shortcut, group[i].Target)); + allArguments.AddRange(BuildWindowsTerminalArguments(group[i].Shortcut, target)); } var startInfo = CreateWtStartInfo(allArguments, hostExecutable); From 26e63948b312c12dd4b1ab2b2c47a79e5e2a33bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:14:02 +0000 Subject: [PATCH 3/4] Address second round of review feedback: null-safety and syntax fix - Fix CollectionDefinition placeholder class: a bodyless semicolon declaration isn't valid for a plain (non-record) class. - OpenGroup now fails fast if a resolved target has no host executable, and uses a null-safe string.Equals for the host-executable comparison instead of an instance .Equals call. - Normalize the HostExecutable grouping key (case-insensitive, null-safe) so otherwise-compatible Windows Terminal entries aren't split into separate groups by casing differences. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QA3QpUk1crPby6PYjnZJ2Y --- QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs | 4 +++- QuickShell.Core/Services/ShortcutLaunchExecutor.cs | 2 +- QuickShell.Core/Services/TerminalLauncher.cs | 7 ++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs b/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs index c5681ac..2acf501 100644 --- a/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs +++ b/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs @@ -9,7 +9,9 @@ namespace QuickShell.Core.Tests; /// hook so they can't race with each other under xUnit's default cross-class parallelization. /// [CollectionDefinition("TerminalLauncher StartProcessOverride", DisableParallelization = true)] -public sealed class TerminalLauncherOverrideCollection; +public sealed class TerminalLauncherOverrideCollection +{ +} [Collection("TerminalLauncher StartProcessOverride")] public sealed class ShortcutLaunchExecutorTests diff --git a/QuickShell.Core/Services/ShortcutLaunchExecutor.cs b/QuickShell.Core/Services/ShortcutLaunchExecutor.cs index 0785ac2..76c46a1 100644 --- a/QuickShell.Core/Services/ShortcutLaunchExecutor.cs +++ b/QuickShell.Core/Services/ShortcutLaunchExecutor.cs @@ -254,7 +254,7 @@ private static List> GroupPlans(List plans) continue; } - var key = (plan.Resolved.Target.HostExecutable, plan.EffectiveElevation); + var key = ((plan.Resolved.Target.HostExecutable ?? string.Empty).ToUpperInvariant(), plan.EffectiveElevation); if (groupIndexByKey.TryGetValue(key, out var index)) { groups[index].Add(plan); diff --git a/QuickShell.Core/Services/TerminalLauncher.cs b/QuickShell.Core/Services/TerminalLauncher.cs index ee454a3..4768440 100644 --- a/QuickShell.Core/Services/TerminalLauncher.cs +++ b/QuickShell.Core/Services/TerminalLauncher.cs @@ -84,13 +84,18 @@ public static void OpenGroup(IReadOnlyList group, bool effective } var hostExecutable = group[0].Target.HostExecutable; + if (string.IsNullOrWhiteSpace(hostExecutable)) + { + throw new ArgumentException("Resolved launch target has no host executable.", nameof(group)); + } + var allArguments = new List(); for (var i = 0; i < group.Count; i++) { var target = group[i].Target; if (target.Kind is not (LaunchTargetKind.WindowsTerminal or LaunchTargetKind.IntelligentTerminal) - || !target.HostExecutable.Equals(hostExecutable, StringComparison.OrdinalIgnoreCase)) + || !string.Equals(target.HostExecutable, hostExecutable, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException( "All entries in a group must be Windows Terminal-hosted and share the same host executable.", From a3d66c546ea25e85b3043ed2bd9dc01c219b99d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:48:10 +0000 Subject: [PATCH 4/4] ci: add workflow_dispatch as a manual trigger fallback Gives a way to manually fire CI from the Actions tab or API when a push doesn't generate a synchronize webhook (observed with non-standard push paths where the ref updates on GitHub but no event is delivered). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QA3QpUk1crPby6PYjnZJ2Y --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a6697d..4a9d95a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: # Stacked PRs whose base branch predates this file may need master merged in # before checks appear on downstream PRs (#5–#9). pull_request: + # Manual escape hatch: lets a run be dispatched via the Actions tab or API + # when a synchronize webhook doesn't fire (e.g. non-standard push paths). + workflow_dispatch: permissions: contents: read