Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ These files are generated copies. Update the source skill definitions instead, t
Write GitHub Actions and release automation logic in Go when it needs JSON parsing, workflow polling, state transitions, or non-trivial branching.
Shell scripts are acceptable only as thin wrappers or simple command sequences.

## Dispatcher Release Inputs

The global dispatcher has its own release version in `cli/dispatcher-contract.json`.
When changing dispatcher release inputs, update `dispatcherVersion` in the same PR.
Pull request CI runs `check-dispatcher-version-bump` and fails if dispatcher inputs changed
without a dispatcher version increase.

The authoritative dispatcher release input list is `dispatcherReleaseInputPatterns` in
`cli/internal/automation/dispatcher_version_bump_guard.go`.
Dispatcher release inputs include these non-exhaustive examples:

- `cli/cmd/dispatcher/main.go`
- `cli/dispatcher-contract.json`
- `cli/internal/cli/dispatcher*.go`
- `cli/internal/update/*.go`
- `scripts/install.ps1`

Do not bump `dispatcherContractVersion` unless the dispatcher contract itself changes.
Do not raise Unity package `MINIMUM_REQUIRED_DISPATCHER_VERSION` or pin-file
`minimumDispatcherVersion` just because the dispatcher implementation changed; those should
advance only when the package must require a newly published dispatcher.

## Windows Compatibility Guardrails

Most day-to-day development happens on macOS, but this project must keep working on Windows.
Expand Down
2 changes: 1 addition & 1 deletion cli/dispatcher-contract.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schemaVersion": 1,
"dispatcherVersion": "3.0.1-beta.6",
"dispatcherVersion": "3.0.1-beta.7",
"dispatcherContractVersion": 1
}
2 changes: 2 additions & 0 deletions cli/internal/cli/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ func enforceDispatcherFreshness(ctx context.Context, pin dispatcherPin, stderr i
return true, 1
}

// Why: optional update failures should not retry and redraw installer progress on every command.
markDispatcherSelfUpdateChecked()
writeFormat(stderr, "warning: dispatcher self-update skipped: %v\n", err)
return false, 0
}
Expand Down
26 changes: 22 additions & 4 deletions cli/internal/cli/dispatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,16 +229,18 @@ func TestEnforceDispatcherFreshnessRequiresManualUpdateWhenSelfUpdateDisabled(t
}
}

func TestEnforceDispatcherFreshnessDoesNotMarkFailedOptionalUpdateChecked(t *testing.T) {
// Verifies transient optional update failures stay retryable on the next command.
func TestEnforceDispatcherFreshnessMarksFailedOptionalUpdateChecked(t *testing.T) {
// Verifies transient optional update failures are throttled until the next check interval.
cacheRoot := t.TempDir()
t.Setenv(dispatcherCacheDirEnvName, cacheRoot)

previousRunner := dispatcherRunUpdate
defer func() {
dispatcherRunUpdate = previousRunner
}()
runnerCalls := 0
dispatcherRunUpdate = func(context.Context) error {
runnerCalls++
return errors.New("network unavailable")
}

Expand All @@ -255,8 +257,24 @@ func TestEnforceDispatcherFreshnessDoesNotMarkFailedOptionalUpdateChecked(t *tes
t.Fatalf("freshness output mismatch: %s", stderr.String())
}
statePath := filepath.Join(cacheRoot, dispatcherUpdateStateFileName)
if _, err := os.Stat(statePath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected no update state after failed optional update, got err=%v", err)
if _, err := os.Stat(statePath); err != nil {
t.Fatalf("expected update state after failed optional update: %v", err)
}

stderr.Reset()
handled, code = enforceDispatcherFreshness(
context.Background(),
dispatcherPin{MinimumDispatcherVersion: dispatcherVersion},
&stderr)

if handled || code != 0 {
t.Fatalf("second freshness result mismatch: handled=%t code=%d", handled, code)
}
if runnerCalls != 1 {
t.Fatalf("optional update should be throttled after failure, got %d calls", runnerCalls)
}
if stderr.Len() != 0 {
t.Fatalf("expected no throttled optional update output, got: %s", stderr.String())
}
}

Expand Down
10 changes: 8 additions & 2 deletions cli/internal/cli/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestUpdateCommandForDarwinUsesDirectInstaller(t *testing.T) {
}

func TestUpdateCommandForWindowsUsesPowerShellInstaller(t *testing.T) {
// Verifies dispatcher update calls the same Windows installer script on the matching channel.
// Verifies dispatcher update calls the Windows installer script quietly on the matching channel.
commandName, args, err := updateCommandForOS("windows")
if err != nil {
t.Fatalf("updateCommandForOS failed: %v", err)
Expand All @@ -58,6 +58,9 @@ func TestUpdateCommandForWindowsUsesPowerShellInstaller(t *testing.T) {
if !strings.Contains(joinedArgs, "$env:ULOOP_VERSION='"+expectedReleaseTag+"'") {
t.Fatalf("installer version missing: %s", joinedArgs)
}
if !strings.Contains(joinedArgs, "$ProgressPreference='SilentlyContinue'") {
t.Fatalf("installer progress suppression missing: %s", joinedArgs)
}
if strings.Contains(joinedArgs, "npm") {
t.Fatalf("update command still references npm: %s", joinedArgs)
}
Expand Down Expand Up @@ -127,7 +130,7 @@ func TestUpdateCommandForDarwinNormalizesProjectRunnerReleaseTag(t *testing.T) {
}

func TestUpdateCommandForWindowsUsesRequestedVersion(t *testing.T) {
// Verifies Windows dispatcher update can target the minimum release version requested by Unity.
// Verifies Windows dispatcher update can quietly target the minimum release version requested by Unity.
commandName, args, err := updateCommandForOSWithOptions("windows", updateOptions{
targetVersion: "3.0.0",
})
Expand All @@ -145,6 +148,9 @@ func TestUpdateCommandForWindowsUsesRequestedVersion(t *testing.T) {
if !strings.Contains(joinedArgs, "$env:ULOOP_VERSION='dispatcher-v3.0.0'") {
t.Fatalf("installer version missing: %s", joinedArgs)
}
if !strings.Contains(joinedArgs, "$ProgressPreference='SilentlyContinue'") {
t.Fatalf("installer progress suppression missing: %s", joinedArgs)
}
}

func TestParseUpdateOptionsNormalizesVersionPrefix(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion cli/internal/update/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func CommandForOS(goos string, options Options) (Command, error) {
"-ExecutionPolicy",
"Bypass",
"-Command",
fmt.Sprintf("$env:ULOOP_VERSION=%s; irm %s | iex", shellQuote(updateSelector), shellQuote(scriptURL)),
fmt.Sprintf("$ProgressPreference='SilentlyContinue'; $env:ULOOP_VERSION=%s; irm %s | iex", shellQuote(updateSelector), shellQuote(scriptURL)),
}}, nil
default:
return Command{}, errors.New(UnsupportedOSMessage)
Expand Down
Loading