diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
deleted file mode 100644
index 3d9829efb5f4c33adf917a486fde13110811c07a..0000000000000000000000000000000000000000
--- a/.github/workflows/ci.yaml
+++ /dev/null
@@ -1,39 +0,0 @@
-name: ci
-
-on: [push, pull_request]
-
-jobs:
-  fmt:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v1
-      - name: Run ./ci/fmt.sh
-        uses: ./ci/container
-        with:
-          args: ./ci/fmt.sh
-
-  lint:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v1
-      - name: Run ./ci/lint.sh
-        uses: ./ci/container
-        with:
-          args: ./ci/lint.sh
-
-  test:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v1
-      - name: Run ./ci/test.sh
-        uses: ./ci/container
-        with:
-          args: ./ci/test.sh
-        env:
-          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
-          NETLIFY_SITE_ID: 9b3ee4dc-8297-4774-b4b9-a61561fbbce7
-      - name: Upload coverage.html
-        uses: actions/upload-artifact@v2
-        with:
-          name: coverage.html
-          path: ./ci/out/coverage.html
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f31ea711ca1adb89cff9b9cf8210ece65c1d74bd
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,39 @@
+name: ci
+on: [push, pull_request]
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+  cancel-in-progress: true
+
+jobs:
+  fmt:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-go@v4
+        with:
+          go-version-file: ./go.mod
+      - run: ./ci/fmt.sh
+
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - run: go version
+      - uses: actions/setup-go@v4
+        with:
+          go-version-file: ./go.mod
+      - run: ./ci/lint.sh
+
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-go@v4
+        with:
+          go-version-file: ./go.mod
+      - run: ./ci/test.sh
+      - uses: actions/upload-artifact@v2
+        if: always()
+        with:
+          name: coverage.html
+          path: ./ci/out/coverage.html
diff --git a/ci/all.sh b/ci/all.sh
deleted file mode 100755
index 1ee7640ffffa7cf60451a765fb34e8cc61ff632b..0000000000000000000000000000000000000000
--- a/ci/all.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-main() {
-  cd "$(dirname "$0")/.."
-
-  ./ci/fmt.sh
-  ./ci/lint.sh
-  ./ci/test.sh "$@"
-}
-
-main "$@"
diff --git a/ci/container/Dockerfile b/ci/container/Dockerfile
deleted file mode 100644
index e2721b9b0653821f979ec58ff352f03f6ccdb036..0000000000000000000000000000000000000000
--- a/ci/container/Dockerfile
+++ /dev/null
@@ -1,14 +0,0 @@
-FROM golang
-
-RUN apt-get update
-RUN apt-get install -y npm shellcheck chromium
-
-ENV GO111MODULE=on
-RUN go install golang.org/x/tools/cmd/goimports@latest
-RUN go install mvdan.cc/sh/v3/cmd/shfmt@latest
-RUN go install golang.org/x/tools/cmd/stringer@latest
-RUN go install golang.org/x/lint/golint@latest
-RUN go install github.com/agnivade/wasmbrowsertest@latest
-
-RUN npm --unsafe-perm=true install -g prettier
-RUN npm --unsafe-perm=true install -g netlify-cli
diff --git a/ci/fmt.sh b/ci/fmt.sh
index b34f1438502ea1e20cad9f3c9656bda05fa747b7..0d902732290b730b2a873099b3616c35011519e9 100755
--- a/ci/fmt.sh
+++ b/ci/fmt.sh
@@ -1,38 +1,18 @@
-#!/usr/bin/env bash
-set -euo pipefail
+#!/bin/sh
+set -eu
+cd -- "$(dirname "$0")/.."
 
-main() {
-  cd "$(dirname "$0")/.."
+go mod tidy
+gofmt -w -s .
+go run golang.org/x/tools/cmd/goimports@latest -w "-local=$(go list -m)" .
 
-  go mod tidy
-  gofmt -w -s .
-  goimports -w "-local=$(go list -m)" .
+npx prettier@3.0.3 \
+  --write \
+  --log-level=warn \
+  --print-width=90 \
+  --no-semi \
+  --single-quote \
+  --arrow-parens=avoid \
+  $(git ls-files "*.yml" "*.md" "*.js" "*.css" "*.html")
 
-  prettier \
-    --write \
-    --print-width=120 \
-    --no-semi \
-    --trailing-comma=all \
-    --loglevel=warn \
-    --arrow-parens=avoid \
-    $(git ls-files "*.yml" "*.md" "*.js" "*.css" "*.html")
-  shfmt -i 2 -w -s -sr $(git ls-files "*.sh")
-
-  stringer -type=opcode,MessageType,StatusCode -output=stringer.go
-
-  if [[ ${CI-} ]]; then
-    assert_no_changes
-  fi
-}
-
-assert_no_changes() {
-  if [[ $(git ls-files --other --modified --exclude-standard) ]]; then
-    git -c color.ui=always --no-pager diff
-    echo
-    echo "Please run the following locally:"
-    echo "  ./ci/fmt.sh"
-    exit 1
-  fi
-}
-
-main "$@"
+go run golang.org/x/tools/cmd/stringer@latest -type=opcode,MessageType,StatusCode -output=stringer.go
diff --git a/ci/lint.sh b/ci/lint.sh
index e1053d130540dab702a75b793cb810e2e6086f36..a8ab30272309bbe29f30beebfc5e68d12c6aefc0 100755
--- a/ci/lint.sh
+++ b/ci/lint.sh
@@ -1,16 +1,14 @@
-#!/usr/bin/env bash
-set -euo pipefail
+#!/bin/sh
+set -eu
+cd -- "$(dirname "$0")/.."
 
-main() {
-  cd "$(dirname "$0")/.."
+go vet ./...
+GOOS=js GOARCH=wasm go vet ./...
 
-  go vet ./...
-  GOOS=js GOARCH=wasm go vet ./...
+go install golang.org/x/lint/golint@latest
+golint -set_exit_status ./...
+GOOS=js GOARCH=wasm golint -set_exit_status ./...
 
-  golint -set_exit_status ./...
-  GOOS=js GOARCH=wasm golint -set_exit_status ./...
-
-  shellcheck --exclude=SC2046 $(git ls-files "*.sh")
-}
-
-main "$@"
+go install honnef.co/go/tools/cmd/staticcheck@latest
+staticcheck ./...
+GOOS=js GOARCH=wasm staticcheck ./...
diff --git a/ci/test.sh b/ci/test.sh
index bd68b80eb08e235358b60166695da6b9f562efff..1b3d6cc365863bc791f68ee26ba7732d3b0f6b55 100755
--- a/ci/test.sh
+++ b/ci/test.sh
@@ -1,25 +1,14 @@
-#!/usr/bin/env bash
-set -euo pipefail
+#!/bin/sh
+set -eu
+cd -- "$(dirname "$0")/.."
 
-main() {
-  cd "$(dirname "$0")/.."
+go install github.com/agnivade/wasmbrowsertest@latest
+go test --race --timeout=1h --covermode=atomic --coverprofile=ci/out/coverage.prof --coverpkg=./... "$@" ./...
+sed -i.bak '/stringer\.go/d' ci/out/coverage.prof
+sed -i.bak '/nhooyr.io\/websocket\/internal\/test/d' ci/out/coverage.prof
+sed -i.bak '/examples/d' ci/out/coverage.prof
 
-  go test -timeout=30m -covermode=atomic -coverprofile=ci/out/coverage.prof -coverpkg=./... "$@" ./...
-  sed -i.bak '/stringer\.go/d' ci/out/coverage.prof
-  sed -i.bak '/nhooyr.io\/websocket\/internal\/test/d' ci/out/coverage.prof
-  sed -i.bak '/examples/d' ci/out/coverage.prof
+# Last line is the total coverage.
+go tool cover -func ci/out/coverage.prof | tail -n1
 
-  # Last line is the total coverage.
-  go tool cover -func ci/out/coverage.prof | tail -n1
-
-  go tool cover -html=ci/out/coverage.prof -o=ci/out/coverage.html
-
-  if [[ ${CI-} && ${GITHUB_REF-} == *master ]]; then
-    local deployDir
-    deployDir="$(mktemp -d)"
-    cp ci/out/coverage.html "$deployDir/index.html"
-    netlify deploy --prod "--dir=$deployDir"
-  fi
-}
-
-main "$@"
+go tool cover -html=ci/out/coverage.prof -o=ci/out/coverage.html
diff --git a/examples/chat/index.css b/examples/chat/index.css
index 73a8e0f3af030e225106234ef634a3aea1e5be3d..ce27c378dd0ff524b4649612de66fee5b02528ba 100644
--- a/examples/chat/index.css
+++ b/examples/chat/index.css
@@ -54,7 +54,7 @@ body {
   margin: 0 0 0 10px;
 }
 
-#publish-form input[type="text"] {
+#publish-form input[type='text'] {
   flex-grow: 1;
 
   -moz-appearance: none;
@@ -64,7 +64,7 @@ body {
   border: 1px solid #ccc;
 }
 
-#publish-form input[type="submit"] {
+#publish-form input[type='submit'] {
   color: white;
   background-color: black;
   border-radius: 5px;
@@ -72,10 +72,10 @@ body {
   border: none;
 }
 
-#publish-form input[type="submit"]:hover {
+#publish-form input[type='submit']:hover {
   background-color: red;
 }
 
-#publish-form input[type="submit"]:active {
+#publish-form input[type='submit']:active {
   background-color: red;
 }
diff --git a/examples/chat/index.html b/examples/chat/index.html
index 76ae8370149c2415bfd4925bacc9b886b3b89710..64edd28691f635072522d16406c2856b7a11b5a4 100644
--- a/examples/chat/index.html
+++ b/examples/chat/index.html
@@ -1,4 +1,4 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="en-CA">
   <head>
     <meta charset="UTF-8" />
diff --git a/examples/chat/index.js b/examples/chat/index.js
index 5868e7caeeeebbdfa6cbe5d0a4a8c82f9268ab98..2efca0133918aa75065c6a3672ef9aeaef2409ba 100644
--- a/examples/chat/index.js
+++ b/examples/chat/index.js
@@ -6,21 +6,21 @@
   function dial() {
     const conn = new WebSocket(`ws://${location.host}/subscribe`)
 
-    conn.addEventListener("close", ev => {
+    conn.addEventListener('close', ev => {
       appendLog(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`, true)
       if (ev.code !== 1001) {
-        appendLog("Reconnecting in 1s", true)
+        appendLog('Reconnecting in 1s', true)
         setTimeout(dial, 1000)
       }
     })
-    conn.addEventListener("open", ev => {
-      console.info("websocket connected")
+    conn.addEventListener('open', ev => {
+      console.info('websocket connected')
     })
 
     // This is where we handle messages received.
-    conn.addEventListener("message", ev => {
-      if (typeof ev.data !== "string") {
-        console.error("unexpected message type", typeof ev.data)
+    conn.addEventListener('message', ev => {
+      if (typeof ev.data !== 'string') {
+        console.error('unexpected message type', typeof ev.data)
         return
       }
       const p = appendLog(ev.data)
@@ -32,38 +32,38 @@
   }
   dial()
 
-  const messageLog = document.getElementById("message-log")
-  const publishForm = document.getElementById("publish-form")
-  const messageInput = document.getElementById("message-input")
+  const messageLog = document.getElementById('message-log')
+  const publishForm = document.getElementById('publish-form')
+  const messageInput = document.getElementById('message-input')
 
   // appendLog appends the passed text to messageLog.
   function appendLog(text, error) {
-    const p = document.createElement("p")
+    const p = document.createElement('p')
     // Adding a timestamp to each message makes the log easier to read.
     p.innerText = `${new Date().toLocaleTimeString()}: ${text}`
     if (error) {
-      p.style.color = "red"
-      p.style.fontStyle = "bold"
+      p.style.color = 'red'
+      p.style.fontStyle = 'bold'
     }
     messageLog.append(p)
     return p
   }
-  appendLog("Submit a message to get started!")
+  appendLog('Submit a message to get started!')
 
   // onsubmit publishes the message from the user when the form is submitted.
   publishForm.onsubmit = async ev => {
     ev.preventDefault()
 
     const msg = messageInput.value
-    if (msg === "") {
+    if (msg === '') {
       return
     }
-    messageInput.value = ""
+    messageInput.value = ''
 
     expectingMessage = true
     try {
-      const resp = await fetch("/publish", {
-        method: "POST",
+      const resp = await fetch('/publish', {
+        method: 'POST',
         body: msg,
       })
       if (resp.status !== 202) {
diff --git a/make.sh b/make.sh
index 578203cdc587f813b74a21fcf2839e9f00bd62b5..6f5d1f5709a6990cbc9c4959ec9ad6e6dafd818d 100755
--- a/make.sh
+++ b/make.sh
@@ -1,17 +1,7 @@
 #!/bin/sh
 set -eu
+cd -- "$(dirname "$0")"
 
-cd "$(dirname "$0")"
-
-fmt() {
-	go mod tidy
-	gofmt -s -w .
-	goimports -w "-local=$(go list -m)" .
-}
-
-if ! command -v wasmbrowsertest >/dev/null; then
-	go install github.com/agnivade/wasmbrowsertest@latest
-fi
-
-fmt
-go test -race --timeout=1h ./... "$@"
+./ci/fmt.sh
+./ci/lint.sh
+./ci/test.sh