From df498dc81ce4896f48345d6bd845c8952f8bad90 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 4 Sep 2026 04:28:31 -0700 Subject: [PATCH 1/3] chore: refresh Go build and CI tooling --- .github/workflows/ci.yml | 17 +++++++++++++++-- .github/workflows/pages.yml | 4 ++-- CHANGELOG.md | 3 +++ Dockerfile | 4 ++-- docs/install.md | 2 ++ docs/testing.md | 1 + go.mod | 2 ++ 7 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index beb8978..fc459ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.x" + go-version-file: go.mod cache: true - name: Format check (gofmt) @@ -50,5 +50,18 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v9.3.0 with: - version: v2.12.2 + version: v2.13.2 args: ./... + + minimum-go: + runs-on: ubuntu-latest + env: + GOTOOLCHAIN: local + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: "1.26.0" + cache: true + - name: Test minimum supported Go version + run: go test ./... diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e01516a..2e837cb 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -32,7 +32,7 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - name: Check out - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -54,4 +54,4 @@ jobs: - name: Deploy id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index d7941bf..5e0bb6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased] +### Changed +- Prefer Go 1.26.8 for source, CI, and Docker builds while retaining Go 1.26 compatibility; refresh lint tooling, the Dockerfile frontend, and pinned documentation workflow actions. + ## [0.3.4] - 2026-07-17 ### Highlights diff --git a/Dockerfile b/Dockerfile index 47da93f..fd85c06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -# syntax=docker/dockerfile:1.7 +# syntax=docker/dockerfile:1.27.0 -ARG GO_VERSION=1.26 +ARG GO_VERSION=1.26.8 FROM golang:${GO_VERSION}-bookworm AS build WORKDIR /src diff --git a/docs/install.md b/docs/install.md index 4976605..bb1a848 100644 --- a/docs/install.md +++ b/docs/install.md @@ -28,6 +28,8 @@ go install github.com/steipete/sonoscli/cmd/sonos@latest sonos --version ``` +Source builds retain Go 1.26 as the minimum and prefer Go 1.26.8 via `go.mod`. CI and Docker builds use the same preferred patch version. + The binary lands in `$(go env GOBIN)` (defaults to `$HOME/go/bin`) — make sure that's on your `PATH`. ## Prebuilt release archives diff --git a/docs/testing.md b/docs/testing.md index d9f3ea7..45663dc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -28,6 +28,7 @@ Expected: - All commands exit `0` - CI should match `.github/workflows/ci.yml` (`gofmt`, coverage, `golangci-lint`, race tests, `go vet`) - CI enforces a minimum total coverage of `75%` (statement coverage across `./...`) and a focused `85%` floor for the stream proxy package. +- The main CI job uses the preferred Go 1.26.8 toolchain from `go.mod`; a separate job tests the Go 1.26.0 source minimum with automatic toolchain switching disabled. ## Live network test plan (manual) diff --git a/go.mod b/go.mod index bf75bbd..56b1167 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/steipete/sonoscli go 1.26 +toolchain go1.26.8 + require github.com/spf13/cobra v1.10.2 require ( From 49d08dd87b6bed682ea6fb0325124a7c537ae482 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 4 Sep 2026 04:34:16 -0700 Subject: [PATCH 2/3] fix: resolve initial watch event services after subscribing --- CHANGELOG.md | 3 +++ docs/commands/sonos-watch.md | 2 ++ internal/cli/watch.go | 27 +++++++++++++-------------- internal/cli/watch_cmd_test.go | 6 ++++++ 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0bb6b..bfef86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased] +### Fixed +- Label initial `watch` notifications with the correct service even when they arrive before subscription setup finishes. + ### Changed - Prefer Go 1.26.8 for source, CI, and Docker builds while retaining Go 1.26 compatibility; refresh lint tooling, the Dockerfile frontend, and pinned documentation workflow actions. diff --git a/docs/commands/sonos-watch.md b/docs/commands/sonos-watch.md index 7aa22f2..c2fe2e0 100644 --- a/docs/commands/sonos-watch.md +++ b/docs/commands/sonos-watch.md @@ -5,6 +5,8 @@ description: Subscribe to live AVTransport and RenderingControl events from a sp # `sonos watch` +Initial notifications are queued during subscription setup and labeled with their service once the subscriptions are established. + Subscribes to UPnP eventing on the target speaker and prints state changes as they arrive. Ctrl+C to stop. Useful for debugging, dashboards, or piping into another script. ## Synopsis diff --git a/internal/cli/watch.go b/internal/cli/watch.go index 828575e..c3a1581 100644 --- a/internal/cli/watch.go +++ b/internal/cli/watch.go @@ -11,7 +11,6 @@ import ( "os/signal" "sort" "strings" - "sync" "time" "github.com/spf13/cobra" @@ -80,7 +79,6 @@ func newWatchCmd(flags *rootFlags) *cobra.Command { callbackURL := fmt.Sprintf("http://%s:%d/notify", listenIP, port) events := make(chan watchEvent, 128) - var sidToService sync.Map // sid -> service name mux := http.NewServeMux() mux.HandleFunc("/notify", func(w http.ResponseWriter, r *http.Request) { @@ -93,11 +91,6 @@ func newWatchCmd(flags *rootFlags) *cobra.Command { body, _ := io.ReadAll(r.Body) _ = r.Body.Close() - service := "unknown" - if v, ok := sidToService.Load(sid); ok { - service = v.(string) - } - vars, err := sonos.ParseEvent(body) if err != nil { vars = map[string]string{"parse_error": err.Error()} @@ -105,11 +98,10 @@ func newWatchCmd(flags *rootFlags) *cobra.Command { select { case events <- watchEvent{ - Time: time.Now().UTC(), - Service: service, - SID: sid, - Seq: seq, - Vars: vars, + Time: time.Now().UTC(), + SID: sid, + Seq: seq, + Vars: vars, }: default: // Drop if the consumer is too slow. @@ -130,14 +122,16 @@ func newWatchCmd(flags *rootFlags) *cobra.Command { return err } defer func() { _ = c.Unsubscribe(context.Background(), avtSub) }() - sidToService.Store(avtSub.SID, "avtransport") rcSub, err := c.SubscribeRenderingControl(ctx, callbackURL, 0) if err != nil { return err } defer func() { _ = c.Unsubscribe(context.Background(), rcSub) }() - sidToService.Store(rcSub.SID, "renderingcontrol") + sidToService := map[string]string{ + avtSub.SID: "avtransport", + rcSub.SID: "renderingcontrol", + } if !isJSON(flags) && !isTSV(flags) { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Watching events (callback %s). Press Ctrl+C to stop.\n", callbackURL) @@ -148,6 +142,11 @@ func newWatchCmd(flags *rootFlags) *cobra.Command { case <-ctx.Done(): return nil case ev := <-events: + // Initial NOTIFY requests can arrive before SUBSCRIBE returns its SID. + ev.Service = sidToService[ev.SID] + if ev.Service == "" { + ev.Service = "unknown" + } if isJSON(flags) { _ = writeJSONLine(cmd, ev) continue diff --git a/internal/cli/watch_cmd_test.go b/internal/cli/watch_cmd_test.go index 5ec52a4..1ee0fbf 100644 --- a/internal/cli/watch_cmd_test.go +++ b/internal/cli/watch_cmd_test.go @@ -49,6 +49,9 @@ func TestWatchCmdValidatesTarget(t *testing.T) { func TestWatchCmdEmitsJSONEvent(t *testing.T) { // Fake Sonos speaker that accepts SUBSCRIBE and records callback URL. callbackCh := make(chan string, 1) + notifyDone := make(chan struct{}) + releaseSubscribe := sync.OnceFunc(func() { close(notifyDone) }) + defer releaseSubscribe() handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -67,6 +70,8 @@ func TestWatchCmdEmitsJSONEvent(t *testing.T) { case callbackCh <- cb: default: } + // Deliver the initial event before the subscription response can be read. + <-notifyDone return case r.Method == "SUBSCRIBE" && r.URL.Path == "/MediaRenderer/RenderingControl/Event": w.Header().Set("SID", "uuid:rc") @@ -137,6 +142,7 @@ func TestWatchCmdEmitsJSONEvent(t *testing.T) { } _, _ = io.ReadAll(resp.Body) _ = resp.Body.Close() + releaseSubscribe() select { case err := <-errCh: From 8d18787c2e26254fd7c54444fb7b84943c57d3a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 4 Sep 2026 04:37:58 -0700 Subject: [PATCH 3/3] test: reproduce watch race after subscription response receipt --- internal/cli/watch_cmd_test.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/cli/watch_cmd_test.go b/internal/cli/watch_cmd_test.go index 1ee0fbf..c22175f 100644 --- a/internal/cli/watch_cmd_test.go +++ b/internal/cli/watch_cmd_test.go @@ -49,6 +49,7 @@ func TestWatchCmdValidatesTarget(t *testing.T) { func TestWatchCmdEmitsJSONEvent(t *testing.T) { // Fake Sonos speaker that accepts SUBSCRIBE and records callback URL. callbackCh := make(chan string, 1) + responseReceived := make(chan struct{}) notifyDone := make(chan struct{}) releaseSubscribe := sync.OnceFunc(func() { close(notifyDone) }) defer releaseSubscribe() @@ -70,8 +71,6 @@ func TestWatchCmdEmitsJSONEvent(t *testing.T) { case callbackCh <- cb: default: } - // Deliver the initial event before the subscription response can be read. - <-notifyDone return case r.Method == "SUBSCRIBE" && r.URL.Path == "/MediaRenderer/RenderingControl/Event": w.Header().Set("SID", "uuid:rc") @@ -98,7 +97,15 @@ func TestWatchCmdEmitsJSONEvent(t *testing.T) { return &sonos.Client{ IP: u.Hostname(), Port: port, - HTTP: srv.Client(), + HTTP: &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + resp, err := srv.Client().Transport.RoundTrip(r) + if err == nil && r.Method == "SUBSCRIBE" && r.URL.Path == "/MediaRenderer/AVTransport/Event" { + // UPnP delivers the response first; application SID registration can still lag. + close(responseReceived) + <-notifyDone + } + return resp, err + })}, } } @@ -121,6 +128,11 @@ func TestWatchCmdEmitsJSONEvent(t *testing.T) { case <-time.After(1 * time.Second): t.Fatalf("timed out waiting for subscribe callback") } + select { + case <-responseReceived: + case <-time.After(1 * time.Second): + t.Fatalf("timed out waiting for subscription response") + } // Emit a NOTIFY event to the callback server. ev := `` +