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..bfef86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [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. + ## [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/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/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 ( 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..c22175f 100644 --- a/internal/cli/watch_cmd_test.go +++ b/internal/cli/watch_cmd_test.go @@ -49,6 +49,10 @@ 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() handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -93,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 + })}, } } @@ -116,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 := `` + @@ -137,6 +154,7 @@ func TestWatchCmdEmitsJSONEvent(t *testing.T) { } _, _ = io.ReadAll(resp.Body) _ = resp.Body.Close() + releaseSubscribe() select { case err := <-errCh: