Skip to content

Commit 21eae0a

Browse files
authored
fix: make Windows v3 workflows reliable (#1818)
1 parent b42f918 commit 21eae0a

28 files changed

Lines changed: 1180 additions & 183 deletions

.github/workflows/build-and-test.yml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,16 @@ jobs:
119119
cache: false
120120

121121
# Windows-only code paths (named pipe transport, winio deadlines, typed error
122-
# classification) are otherwise never executed by CI. Scoped to the transport
123-
# package: the wider test suite still has Windows-incompatible tests (path
124-
# separators, missing .exe suffixes, TCP-only fixtures) that predate this job.
125-
- name: Test native Go CLI transport on Windows
122+
# classification, process management) are otherwise never executed by CI.
123+
# Runs every go.work module: the formerly Windows-incompatible tests (POSIX-only
124+
# fake executables, TCP-only fixtures, shell-script git/gh mocks) now install
125+
# portable fixtures on Windows.
126+
- name: Test native Go CLI on Windows
126127
shell: bash
127-
run: cd cli/common && go test ./unityipc/
128+
run: |
129+
for module in common dispatcher project-runner release-automation; do
130+
(cd "cli/$module" && go test ./...)
131+
done
128132
129133
- name: Test install release filter in Git Bash
130134
shell: bash

.github/workflows/native-cli-publish.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,17 @@ jobs:
213213
exit 1
214214
fi
215215
release_sha="${TARGET_SHA}"
216+
# Why publishing is blocked here: the attestation certificate binds
217+
# SourceRepositoryDigest to the workflow run head (GITHUB_SHA), never
218+
# to the checked-out build commit, so assets published for an older
219+
# release commit can never pass the dispatcher's tag-to-certificate
220+
# verification. Recovery dispatch may only validate builds.
221+
if [ "${should_publish}" = "true" ] && [ "${release_sha}" != "${GITHUB_SHA}" ]; then
222+
echo "Recovery-target dispatch cannot publish release assets: the release commit ${release_sha} differs from the run head ${GITHUB_SHA}, so the attestation certificate digest would never match the release tag." >&2
223+
echo "Rerun the original failed workflow run whose head commit is the release commit instead." >&2
224+
echo "See docs/release-recovery-runbook.md for the recovery procedure." >&2
225+
exit 1
226+
fi
216227
else
217228
if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then
218229
echo "Build SHA and release target must match the approved event commit." >&2

Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationRunInBackgroundScope.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#nullable enable
22
using System;
33

4+
using io.github.hatayama.UnityCliLoop.ToolContracts;
5+
46
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
57
{
68
/// <summary>
@@ -29,6 +31,12 @@ public static InputSimulationRunInBackgroundScope Enable()
2931

3032
public void Dispose()
3133
{
34+
// Precondition: Application.runInBackground is main-thread-only, so callers resuming
35+
// from ConfigureAwait(false) continuations must switch back to the main thread first.
36+
UnityEngine.Debug.Assert(
37+
MainThreadSwitcher.IsMainThread,
38+
"InputSimulationRunInBackgroundScope.Dispose must be called on the main thread.");
39+
3240
if (_isDisposed)
3341
{
3442
return;

Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCLILoop.FirstPartyTools.Common.InputSimulation.Editor.asmdef

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{
22
"name": "UnityCLILoop.FirstPartyTools.Common.InputSimulation.Editor",
33
"rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools",
4-
"references": [],
4+
"references": [
5+
"GUID:fc3fd32eddbee40e39c2d76dc184957b"
6+
],
57
"includePlatforms": [
68
"Editor"
79
],

Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -104,49 +104,59 @@ public async Task<SimulateKeyboardResponse> ExecuteAsync(
104104
correlationId: correlationId
105105
);
106106

107-
using InputSimulationRunInBackgroundScope runInBackgroundScope = InputSimulationRunInBackgroundScope.Enable();
108-
109-
EnsureOverlayExists();
107+
// Why not `using`: the executor awaits below use ConfigureAwait(false), so this method
108+
// can resume on a thread-pool thread. Application.runInBackground is main-thread-only,
109+
// so the scope must be disposed after switching back to the main thread.
110+
InputSimulationRunInBackgroundScope runInBackgroundScope = InputSimulationRunInBackgroundScope.Enable();
111+
try
112+
{
113+
EnsureOverlayExists();
110114

111-
SimulateKeyboardResponse response;
115+
SimulateKeyboardResponse response;
112116

113-
switch (parameters.Action)
117+
switch (parameters.Action)
118+
{
119+
case UnityCliLoopKeyboardAction.Press:
120+
response = await KeyboardInputActionExecutor.ExecutePress(
121+
keyboard,
122+
key,
123+
parameters.Duration,
124+
ct).ConfigureAwait(false);
125+
break;
126+
127+
case UnityCliLoopKeyboardAction.KeyDown:
128+
response = await KeyboardInputActionExecutor.ExecuteKeyDown(keyboard, key, ct).ConfigureAwait(false);
129+
break;
130+
131+
case UnityCliLoopKeyboardAction.KeyUp:
132+
response = await KeyboardInputActionExecutor.ExecuteKeyUp(keyboard, key, ct).ConfigureAwait(false);
133+
break;
134+
135+
default:
136+
// Only reachable when an out-of-range enum value is cast from an integer;
137+
// surface as a Success=false response so the CLI treats it as a normal validation failure.
138+
return new SimulateKeyboardResponse
139+
{
140+
Success = false,
141+
Message = $"Unknown keyboard action: {parameters.Action}",
142+
Action = parameters.Action.ToString()
143+
};
144+
}
145+
146+
VibeLogger.LogInfo(
147+
"simulate_keyboard_complete",
148+
$"Keyboard simulation completed: {response.Message}",
149+
new { Action = parameters.Action.ToString(), Success = response.Success },
150+
correlationId: correlationId
151+
);
152+
153+
return response;
154+
}
155+
finally
114156
{
115-
case UnityCliLoopKeyboardAction.Press:
116-
response = await KeyboardInputActionExecutor.ExecutePress(
117-
keyboard,
118-
key,
119-
parameters.Duration,
120-
ct).ConfigureAwait(false);
121-
break;
122-
123-
case UnityCliLoopKeyboardAction.KeyDown:
124-
response = await KeyboardInputActionExecutor.ExecuteKeyDown(keyboard, key, ct).ConfigureAwait(false);
125-
break;
126-
127-
case UnityCliLoopKeyboardAction.KeyUp:
128-
response = await KeyboardInputActionExecutor.ExecuteKeyUp(keyboard, key, ct).ConfigureAwait(false);
129-
break;
130-
131-
default:
132-
// Only reachable when an out-of-range enum value is cast from an integer;
133-
// surface as a Success=false response so the CLI treats it as a normal validation failure.
134-
return new SimulateKeyboardResponse
135-
{
136-
Success = false,
137-
Message = $"Unknown keyboard action: {parameters.Action}",
138-
Action = parameters.Action.ToString()
139-
};
157+
await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None);
158+
runInBackgroundScope.Dispose();
140159
}
141-
142-
VibeLogger.LogInfo(
143-
"simulate_keyboard_complete",
144-
$"Keyboard simulation completed: {response.Message}",
145-
new { Action = parameters.Action.ToString(), Success = response.Success },
146-
correlationId: correlationId
147-
);
148-
149-
return response;
150160
#endif
151161
}
152162

Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -97,46 +97,56 @@ public async Task<SimulateMouseInputResponse> ExecuteAsync(
9797
correlationId: correlationId
9898
);
9999

100-
using InputSimulationRunInBackgroundScope runInBackgroundScope = InputSimulationRunInBackgroundScope.Enable();
101-
102-
EnsureOverlayExists();
103-
104-
SimulateMouseInputResponse response;
105-
106-
switch (parameters.Action)
100+
// Why not `using`: the executor awaits below use ConfigureAwait(false), so this method
101+
// can resume on a thread-pool thread. Application.runInBackground is main-thread-only,
102+
// so the scope must be disposed after switching back to the main thread.
103+
InputSimulationRunInBackgroundScope runInBackgroundScope = InputSimulationRunInBackgroundScope.Enable();
104+
try
107105
{
108-
case UnityCliLoopMouseInputAction.Click:
109-
response = await MouseInputPressActionExecutor.ExecuteClick(mouse, parameters, ct).ConfigureAwait(false);
110-
break;
111-
112-
case UnityCliLoopMouseInputAction.LongPress:
113-
response = await MouseInputPressActionExecutor.ExecuteLongPress(mouse, parameters, ct).ConfigureAwait(false);
114-
break;
106+
EnsureOverlayExists();
115107

116-
case UnityCliLoopMouseInputAction.MoveDelta:
117-
response = await MouseInputMotionActionExecutor.ExecuteMoveDelta(mouse, parameters, ct).ConfigureAwait(false);
118-
break;
108+
SimulateMouseInputResponse response;
119109

120-
case UnityCliLoopMouseInputAction.Scroll:
121-
response = await MouseInputMotionActionExecutor.ExecuteScroll(mouse, parameters, ct).ConfigureAwait(false);
122-
break;
123-
124-
case UnityCliLoopMouseInputAction.SmoothDelta:
125-
response = await MouseInputMotionActionExecutor.ExecuteSmoothDelta(mouse, parameters, ct).ConfigureAwait(false);
126-
break;
127-
128-
default:
129-
throw new ArgumentException($"Unknown mouse input action: {parameters.Action}");
110+
switch (parameters.Action)
111+
{
112+
case UnityCliLoopMouseInputAction.Click:
113+
response = await MouseInputPressActionExecutor.ExecuteClick(mouse, parameters, ct).ConfigureAwait(false);
114+
break;
115+
116+
case UnityCliLoopMouseInputAction.LongPress:
117+
response = await MouseInputPressActionExecutor.ExecuteLongPress(mouse, parameters, ct).ConfigureAwait(false);
118+
break;
119+
120+
case UnityCliLoopMouseInputAction.MoveDelta:
121+
response = await MouseInputMotionActionExecutor.ExecuteMoveDelta(mouse, parameters, ct).ConfigureAwait(false);
122+
break;
123+
124+
case UnityCliLoopMouseInputAction.Scroll:
125+
response = await MouseInputMotionActionExecutor.ExecuteScroll(mouse, parameters, ct).ConfigureAwait(false);
126+
break;
127+
128+
case UnityCliLoopMouseInputAction.SmoothDelta:
129+
response = await MouseInputMotionActionExecutor.ExecuteSmoothDelta(mouse, parameters, ct).ConfigureAwait(false);
130+
break;
131+
132+
default:
133+
throw new ArgumentException($"Unknown mouse input action: {parameters.Action}");
134+
}
135+
136+
VibeLogger.LogInfo(
137+
"simulate_mouse_input_complete",
138+
$"Mouse input simulation completed: {response.Message}",
139+
new { Action = parameters.Action.ToString(), Success = response.Success },
140+
correlationId: correlationId
141+
);
142+
143+
return response;
144+
}
145+
finally
146+
{
147+
await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None);
148+
runInBackgroundScope.Dispose();
130149
}
131-
132-
VibeLogger.LogInfo(
133-
"simulate_mouse_input_complete",
134-
$"Mouse input simulation completed: {response.Message}",
135-
new { Action = parameters.Action.ToString(), Success = response.Success },
136-
correlationId: correlationId
137-
);
138-
139-
return response;
140150
#endif
141151
}
142152

cli/common/unityprocess/command_error.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package unityprocess
22

33
import (
4+
"context"
5+
"errors"
46
"fmt"
57
"strings"
68
)
@@ -15,3 +17,20 @@ func commandErrorWithStderr(err error, stderr string) error {
1517
}
1618
return fmt.Errorf("%w: %s", err, trimmedStderr)
1719
}
20+
21+
// focusCommandError converts a focus script failure into an actionable error.
22+
// Why: a timeout kills the script before it can write anything to stderr, so without this
23+
// mapping the caller only sees a bare "exit status 1" with no hint about the stalled Editor.
24+
func focusCommandError(contextErr error, runErr error, stderr string) error {
25+
if runErr == nil {
26+
return nil
27+
}
28+
if errors.Is(contextErr, context.DeadlineExceeded) {
29+
return fmt.Errorf(
30+
"focusing the Unity window timed out after %s; the Unity Editor may be busy (for example during a domain reload), retry once it is responsive: %w",
31+
FocusCommandTimeout,
32+
runErr,
33+
)
34+
}
35+
return commandErrorWithStderr(runErr, stderr)
36+
}

cli/common/unityprocess/command_error_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package unityprocess
22

33
import (
4+
"context"
45
"errors"
56
"strings"
67
"testing"
@@ -25,3 +26,31 @@ func TestCommandErrorWithStderrKeepsOriginalErrorWithoutStderr(t *testing.T) {
2526
t.Fatalf("expected original error, got %v", actual)
2627
}
2728
}
29+
30+
// Verifies a timed-out focus script is reported as a busy-Editor timeout instead of a bare exit status.
31+
func TestFocusCommandErrorReportsTimeoutWhenContextDeadlineExceeded(t *testing.T) {
32+
err := focusCommandError(context.DeadlineExceeded, errors.New("exit status 1"), "")
33+
34+
if err == nil || !strings.Contains(err.Error(), "timed out") || !strings.Contains(err.Error(), "domain reload") {
35+
t.Fatalf("expected timeout explanation, got %v", err)
36+
}
37+
}
38+
39+
// Verifies a focus script throw keeps the stderr text without the timeout explanation.
40+
func TestFocusCommandErrorKeepsStderrForScriptFailures(t *testing.T) {
41+
err := focusCommandError(nil, errors.New("exit status 1"), "Windows refused to bring the Unity window\r\n")
42+
43+
if err == nil || !strings.Contains(err.Error(), "Windows refused to bring the Unity window") {
44+
t.Fatalf("expected stderr in error, got %v", err)
45+
}
46+
if strings.Contains(err.Error(), "timed out") {
47+
t.Fatalf("expected no timeout explanation, got %v", err)
48+
}
49+
}
50+
51+
// Verifies a nil run error yields no focus error.
52+
func TestFocusCommandErrorReturnsNilWithoutRunError(t *testing.T) {
53+
if err := focusCommandError(context.DeadlineExceeded, nil, ""); err != nil {
54+
t.Fatalf("expected nil, got %v", err)
55+
}
56+
}

0 commit comments

Comments
 (0)